ovos-wolfram-alpha-plugin 1.0.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.4
2
+ Name: ovos-wolfram-alpha-plugin
3
+ Version: 1.0.0a1
4
+ Summary: ovos wolfram-alpha plugin
5
+ Author-email: JarbasAi <jarbasai@mailfence.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/OpenVoiceOS/ovos-wolfram-alpha-plugin
8
+ Keywords: ovos,plugin,wolfram
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: ovos-utils<1.0.0,>=0.7.0
11
+ Requires-Dist: ovos-plugin-manager<3.0.0,>=2.3.0a1
12
+ Requires-Dist: ovos-config<2.0.0,>=0.0.12
13
+ Requires-Dist: requests>=2.0.0
14
+ Requires-Dist: pydantic<3.0.0,>=2.0.0
15
+
16
+ # ovos-wolfram-alpha-plugin
17
+
18
+ [![PyPI](https://img.shields.io/pypi/v/ovos-wolfram-alpha-plugin)](https://pypi.org/project/ovos-wolfram-alpha-plugin/)
19
+ [![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
20
+ [![Python](https://img.shields.io/badge/python-%3E%3D3.10-blue)](https://www.python.org/)
21
+
22
+ [Wolfram Alpha](https://www.wolframalpha.com/) integration for [OpenVoiceOS](https://openvoiceos.org). Provides a **retrieval engine** for RAG pipelines and an **agent toolbox** for tool-using agents, both as standard OPM plugins.
23
+
24
+ Wolfram Alpha excels at questions with a single definitive answer: maths, unit conversions, scientific constants, chemical properties, astronomy, nutrition, geography, and historical dates. It is not a search engine — it computes answers from curated data.
25
+
26
+ An [API key](https://products.wolframalpha.com/api/) is required. A demo key is bundled for development but is rate-limited and should not be used in production.
27
+
28
+ ---
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install ovos-wolfram-alpha-plugin
34
+ ```
35
+
36
+ ---
37
+
38
+ ## OPM Entry Points
39
+
40
+ | Entry point | Class | Use case |
41
+ |---|---|---|
42
+ | `opm.agents.retrieval` — `ovos-wolfram-alpha-solver` | `WolframAlphaRetrievalEngine` | RAG — returns `(answer, score)` tuples |
43
+ | `opm.agents.toolbox` — `ovos-wolfram-alpha-tools` | `WolframAlphaToolbox` | Agent tool use — exposes `search_wolfram_alpha` |
44
+
45
+ ---
46
+
47
+ ## Retrieval Engine
48
+
49
+ `WolframAlphaRetrievalEngine` implements the `RetrievalEngine` OPM interface. It calls the Wolfram Alpha spoken-answer endpoint and handles non-English queries by translating them to English before the request and back after.
50
+
51
+ ```python
52
+ from ovos_wolfram_alpha_plugin import WolframAlphaRetrievalEngine
53
+
54
+ engine = WolframAlphaRetrievalEngine(config={"appid": "YOUR-KEY"})
55
+
56
+ # Maths & conversions
57
+ engine.get_spoken_answer("integral of x^2 sin(x)", lang="en")
58
+ # "x^2 (-cos(x)) + 2 x sin(x) + 2 cos(x) + constant"
59
+
60
+ engine.get_spoken_answer("100 miles in kilometers", lang="en")
61
+ # "160.934 kilometers"
62
+
63
+ engine.get_spoken_answer("1000 USD in EUR", lang="en")
64
+ # "approximately 923 euros" (live rate)
65
+
66
+ # Science & constants
67
+ engine.get_spoken_answer("speed of light", lang="en")
68
+ # "about 2.998 × 10^8 meters per second"
69
+
70
+ engine.get_spoken_answer("boiling point of ethanol", lang="en")
71
+ # "78.37 degrees Celsius"
72
+
73
+ engine.get_spoken_answer("distance from Earth to Mars", lang="en")
74
+ # "currently about 1.69 AU" (live ephemeris)
75
+
76
+ # Factual lookups
77
+ engine.get_spoken_answer("population of Brazil", lang="en")
78
+ # "approximately 215.3 million people"
79
+
80
+ engine.get_spoken_answer("calories in 100g of almonds", lang="en")
81
+ # "579 kilocalories"
82
+
83
+ engine.get_spoken_answer("when was the Eiffel Tower built", lang="en")
84
+ # "construction was from January 28, 1887 to March 31, 1889"
85
+
86
+ # Non-English — translated automatically
87
+ engine.get_spoken_answer("massa do Sol", lang="pt")
88
+ # "aproximadamente 1,989 × 10^30 kg"
89
+
90
+ # Image result — returns a local file path to a Wolfram visual
91
+ engine.get_image("benzene molecular structure", lang="en")
92
+
93
+ # Full structured pod results — list of {"title", "summary"} dicts
94
+ for pod in engine.get_expanded_answer("Neptune", lang="en"):
95
+ print(pod["title"], "—", pod.get("summary", pod.get("img")))
96
+ # "Orbital period — 164.8 years"
97
+ # "Surface gravity — 11.15 m/s²"
98
+ # ...
99
+
100
+ # RAG interface: List[Tuple[str, float]] (answer, score)
101
+ results = engine.query("half-life of carbon-14", lang="en")
102
+ # [("5730 years", 0.9)]
103
+ ```
104
+
105
+ ### Translation
106
+
107
+ Non-English queries require a translation plugin. Configure it by passing `translate_plugin` in the config:
108
+
109
+ ```python
110
+ engine = WolframAlphaRetrievalEngine(config={
111
+ "appid": "YOUR-KEY",
112
+ "translate_plugin": "ovos-translate-plugin-server",
113
+ })
114
+ ```
115
+
116
+ If no translation plugin is available, only English queries are answered.
117
+
118
+ ---
119
+
120
+ ## Agent Toolbox
121
+
122
+ `WolframAlphaToolbox` exposes a single `search_wolfram_alpha` tool that any OPM-compatible agent loop (e.g. [ovos-agentic-loop](https://github.com/OpenVoiceOS/ovos-agentic-loop)) can discover and call. The tool uses the LLM-optimised Wolfram endpoint, which returns a more structured answer than the spoken endpoint.
123
+
124
+ ### Persona JSON
125
+
126
+ Reference the toolbox by its entry point name inside any agentic persona. Pass a `system_prompt` to the brain plugin so the LLM knows how to query Wolfram correctly:
127
+
128
+ ```json
129
+ {
130
+ "name": "Wolfram Alpha",
131
+ "solvers": ["ovos-react-loop"],
132
+ "ovos-react-loop": {
133
+ "brain": "ovos-chat-openai-plugin",
134
+ "toolboxes": ["ovos-wolfram-alpha-tools"],
135
+ "ovos-chat-openai-plugin": {
136
+ "api_url": "http://localhost:11434/v1/chat/completions",
137
+ "system_prompt": "You have access to Wolfram Alpha. Use it for maths, science, unit conversions, and factual questions with a definite answer. Always send queries in English as concise keywords (e.g. 'France population', not 'how many people live in France'). Use the exponent notation 6*10^14, never 6e14. If the result is not relevant, retry with a more specific query rather than rephrasing."
138
+ }
139
+ }
140
+ }
141
+ ```
142
+
143
+ > 💡 The [official LLM API docs](https://products.wolframalpha.com/llm-api/documentation) have more tips on writing effective Wolfram system prompts.
144
+
145
+ ### Direct usage
146
+
147
+ ```python
148
+ from ovos_wolfram_alpha_plugin import WolframAlphaToolbox, SearchWolframAlphaArgs
149
+
150
+ tb = WolframAlphaToolbox(config={"appid": "YOUR-KEY"})
151
+
152
+ tools = tb.discover_tools()
153
+ # [AgentTool(name="search_wolfram_alpha", ...)]
154
+
155
+ output = tb.search_wolfram(SearchWolframAlphaArgs(query="France population", units="metric"))
156
+ print(output.result)
157
+ ```
158
+
159
+ ---
160
+
161
+ ## License
162
+
163
+ Apache 2.0 — see [LICENSE](LICENSE).
@@ -0,0 +1,148 @@
1
+ # ovos-wolfram-alpha-plugin
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/ovos-wolfram-alpha-plugin)](https://pypi.org/project/ovos-wolfram-alpha-plugin/)
4
+ [![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
5
+ [![Python](https://img.shields.io/badge/python-%3E%3D3.10-blue)](https://www.python.org/)
6
+
7
+ [Wolfram Alpha](https://www.wolframalpha.com/) integration for [OpenVoiceOS](https://openvoiceos.org). Provides a **retrieval engine** for RAG pipelines and an **agent toolbox** for tool-using agents, both as standard OPM plugins.
8
+
9
+ Wolfram Alpha excels at questions with a single definitive answer: maths, unit conversions, scientific constants, chemical properties, astronomy, nutrition, geography, and historical dates. It is not a search engine — it computes answers from curated data.
10
+
11
+ An [API key](https://products.wolframalpha.com/api/) is required. A demo key is bundled for development but is rate-limited and should not be used in production.
12
+
13
+ ---
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pip install ovos-wolfram-alpha-plugin
19
+ ```
20
+
21
+ ---
22
+
23
+ ## OPM Entry Points
24
+
25
+ | Entry point | Class | Use case |
26
+ |---|---|---|
27
+ | `opm.agents.retrieval` — `ovos-wolfram-alpha-solver` | `WolframAlphaRetrievalEngine` | RAG — returns `(answer, score)` tuples |
28
+ | `opm.agents.toolbox` — `ovos-wolfram-alpha-tools` | `WolframAlphaToolbox` | Agent tool use — exposes `search_wolfram_alpha` |
29
+
30
+ ---
31
+
32
+ ## Retrieval Engine
33
+
34
+ `WolframAlphaRetrievalEngine` implements the `RetrievalEngine` OPM interface. It calls the Wolfram Alpha spoken-answer endpoint and handles non-English queries by translating them to English before the request and back after.
35
+
36
+ ```python
37
+ from ovos_wolfram_alpha_plugin import WolframAlphaRetrievalEngine
38
+
39
+ engine = WolframAlphaRetrievalEngine(config={"appid": "YOUR-KEY"})
40
+
41
+ # Maths & conversions
42
+ engine.get_spoken_answer("integral of x^2 sin(x)", lang="en")
43
+ # "x^2 (-cos(x)) + 2 x sin(x) + 2 cos(x) + constant"
44
+
45
+ engine.get_spoken_answer("100 miles in kilometers", lang="en")
46
+ # "160.934 kilometers"
47
+
48
+ engine.get_spoken_answer("1000 USD in EUR", lang="en")
49
+ # "approximately 923 euros" (live rate)
50
+
51
+ # Science & constants
52
+ engine.get_spoken_answer("speed of light", lang="en")
53
+ # "about 2.998 × 10^8 meters per second"
54
+
55
+ engine.get_spoken_answer("boiling point of ethanol", lang="en")
56
+ # "78.37 degrees Celsius"
57
+
58
+ engine.get_spoken_answer("distance from Earth to Mars", lang="en")
59
+ # "currently about 1.69 AU" (live ephemeris)
60
+
61
+ # Factual lookups
62
+ engine.get_spoken_answer("population of Brazil", lang="en")
63
+ # "approximately 215.3 million people"
64
+
65
+ engine.get_spoken_answer("calories in 100g of almonds", lang="en")
66
+ # "579 kilocalories"
67
+
68
+ engine.get_spoken_answer("when was the Eiffel Tower built", lang="en")
69
+ # "construction was from January 28, 1887 to March 31, 1889"
70
+
71
+ # Non-English — translated automatically
72
+ engine.get_spoken_answer("massa do Sol", lang="pt")
73
+ # "aproximadamente 1,989 × 10^30 kg"
74
+
75
+ # Image result — returns a local file path to a Wolfram visual
76
+ engine.get_image("benzene molecular structure", lang="en")
77
+
78
+ # Full structured pod results — list of {"title", "summary"} dicts
79
+ for pod in engine.get_expanded_answer("Neptune", lang="en"):
80
+ print(pod["title"], "—", pod.get("summary", pod.get("img")))
81
+ # "Orbital period — 164.8 years"
82
+ # "Surface gravity — 11.15 m/s²"
83
+ # ...
84
+
85
+ # RAG interface: List[Tuple[str, float]] (answer, score)
86
+ results = engine.query("half-life of carbon-14", lang="en")
87
+ # [("5730 years", 0.9)]
88
+ ```
89
+
90
+ ### Translation
91
+
92
+ Non-English queries require a translation plugin. Configure it by passing `translate_plugin` in the config:
93
+
94
+ ```python
95
+ engine = WolframAlphaRetrievalEngine(config={
96
+ "appid": "YOUR-KEY",
97
+ "translate_plugin": "ovos-translate-plugin-server",
98
+ })
99
+ ```
100
+
101
+ If no translation plugin is available, only English queries are answered.
102
+
103
+ ---
104
+
105
+ ## Agent Toolbox
106
+
107
+ `WolframAlphaToolbox` exposes a single `search_wolfram_alpha` tool that any OPM-compatible agent loop (e.g. [ovos-agentic-loop](https://github.com/OpenVoiceOS/ovos-agentic-loop)) can discover and call. The tool uses the LLM-optimised Wolfram endpoint, which returns a more structured answer than the spoken endpoint.
108
+
109
+ ### Persona JSON
110
+
111
+ Reference the toolbox by its entry point name inside any agentic persona. Pass a `system_prompt` to the brain plugin so the LLM knows how to query Wolfram correctly:
112
+
113
+ ```json
114
+ {
115
+ "name": "Wolfram Alpha",
116
+ "solvers": ["ovos-react-loop"],
117
+ "ovos-react-loop": {
118
+ "brain": "ovos-chat-openai-plugin",
119
+ "toolboxes": ["ovos-wolfram-alpha-tools"],
120
+ "ovos-chat-openai-plugin": {
121
+ "api_url": "http://localhost:11434/v1/chat/completions",
122
+ "system_prompt": "You have access to Wolfram Alpha. Use it for maths, science, unit conversions, and factual questions with a definite answer. Always send queries in English as concise keywords (e.g. 'France population', not 'how many people live in France'). Use the exponent notation 6*10^14, never 6e14. If the result is not relevant, retry with a more specific query rather than rephrasing."
123
+ }
124
+ }
125
+ }
126
+ ```
127
+
128
+ > 💡 The [official LLM API docs](https://products.wolframalpha.com/llm-api/documentation) have more tips on writing effective Wolfram system prompts.
129
+
130
+ ### Direct usage
131
+
132
+ ```python
133
+ from ovos_wolfram_alpha_plugin import WolframAlphaToolbox, SearchWolframAlphaArgs
134
+
135
+ tb = WolframAlphaToolbox(config={"appid": "YOUR-KEY"})
136
+
137
+ tools = tb.discover_tools()
138
+ # [AgentTool(name="search_wolfram_alpha", ...)]
139
+
140
+ output = tb.search_wolfram(SearchWolframAlphaArgs(query="France population", units="metric"))
141
+ print(output.result)
142
+ ```
143
+
144
+ ---
145
+
146
+ ## License
147
+
148
+ Apache 2.0 — see [LICENSE](LICENSE).
@@ -0,0 +1,362 @@
1
+ # Licensed under the Apache License, Version 2.0 (the "License");
2
+ # you may not use this file except in compliance with the License.
3
+ # You may obtain a copy of the License at
4
+ #
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ #
7
+ # Unless required by applicable law or agreed to in writing, software
8
+ # distributed under the License is distributed on an "AS IS" BASIS,
9
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10
+ # See the License for the specific language governing permissions and
11
+ # limitations under the License.
12
+ import tempfile
13
+ from os.path import join, isfile
14
+ from typing import Optional, Tuple, Dict, Any, List
15
+
16
+ import requests
17
+ from ovos_config import Configuration
18
+ from ovos_plugin_manager.templates.language import LanguageTranslator
19
+ from ovos_plugin_manager.templates.agent_tools import AgentTool, ToolBox, ToolOutput, ToolArguments
20
+ from ovos_plugin_manager.templates.agents import RetrievalEngine
21
+ from ovos_utils.text_utils import rm_parentheses
22
+ from pydantic import Field
23
+ from ovos_utils.log import LOG
24
+ from ovos_plugin_manager.language import load_tx_plugin
25
+
26
+
27
+ class WolframAlphaApi:
28
+ def __init__(self, key: str):
29
+ self.key = key or "Y7R353-9HQAAL8KKA"
30
+
31
+ @staticmethod
32
+ def _get_lat_lon(**kwargs):
33
+ lat = kwargs.get("latitude") or kwargs.get("lat")
34
+ lon = kwargs.get("longitude") or kwargs.get("lon") or kwargs.get("lng")
35
+ if not lat or not lon:
36
+ cfg = Configuration().get("location", {}).get("coordinate", {})
37
+ lat = cfg.get("latitude")
38
+ lon = cfg.get("longitude")
39
+ return lat, lon
40
+
41
+ def spoken(self, query, units="metric", lat_lon=None, optional_params=None):
42
+ optional_params = optional_params or {}
43
+ if not lat_lon:
44
+ lat_lon = self._get_lat_lon(**optional_params)
45
+ params = {'i': query,
46
+ "geolocation": "{},{}".format(*lat_lon),
47
+ 'units': units,
48
+ "appid": self.key,
49
+ **optional_params}
50
+ url = 'https://api.wolframalpha.com/v1/spoken'
51
+ return requests.get(url, params=params).text
52
+
53
+ def simple(self, query, units="metric", lat_lon=None, optional_params=None):
54
+ optional_params = optional_params or {}
55
+ if not lat_lon:
56
+ lat_lon = self._get_lat_lon(**optional_params)
57
+ params = {'i': query,
58
+ "geolocation": "{},{}".format(*lat_lon),
59
+ 'units': units,
60
+ "appid": self.key,
61
+ **optional_params}
62
+ url = 'https://api.wolframalpha.com/v1/simple'
63
+ return requests.get(url, params=params).text
64
+
65
+ def query_recognizer(self, query, units="metric", lat_lon=None, optional_params=None):
66
+ optional_params = optional_params or {}
67
+ params = {'i': query,
68
+ "appid": self.key,
69
+ "output": "json",
70
+ "mode": "Voice",
71
+ **optional_params}
72
+ url = 'https://www.wolframalpha.com/queryrecognizer/query.jsp'
73
+ # TODO - {"status":401,"message":"Not permitted"}
74
+ # what am i doin wrong? https://products.wolframalpha.com/query-recognizer/documentation
75
+ return requests.get(url, params=params).json()
76
+
77
+ def llm(self, query, units="metric", lat_lon=None, optional_params=None):
78
+ optional_params = optional_params or {}
79
+ if not lat_lon:
80
+ lat_lon = self._get_lat_lon(**optional_params)
81
+ params = {'input': query,
82
+ "geolocation": "{},{}".format(*lat_lon),
83
+ 'units': units,
84
+ "appid": self.key,
85
+ **optional_params}
86
+ url = 'https://api.wolframalpha.com/v1/llm-api'
87
+ return requests.get(url, params=params).text
88
+
89
+ def full_results(self, query, units="metric", lat_lon=None, optional_params=None):
90
+ """Wrapper for the WolframAlpha Full Results v2 API.
91
+ https://products.wolframalpha.com/api/documentation/
92
+ Pods of interest
93
+ - Input interpretation - Wolfram's determination of what is being asked about.
94
+ - Name - primary name of
95
+ """
96
+ optional_params = optional_params or {}
97
+ if not lat_lon:
98
+ lat_lon = self._get_lat_lon(**optional_params)
99
+ params = {'input': query,
100
+ "units": units,
101
+ "mode": "Default",
102
+ "format": "image,plaintext",
103
+ "geolocation": "{},{}".format(*lat_lon),
104
+ "output": "json",
105
+ "appid": self.key,
106
+ **optional_params}
107
+ url = 'https://api.wolframalpha.com/v2/query'
108
+ data = requests.get(url, params=params)
109
+ return data.json()
110
+
111
+ def get_image(self, query: str, units: Optional[str] = None):
112
+ """Return path to a image result for the query."""
113
+ units = units or Configuration().get("system_unit", "metric")
114
+ url = 'http://api.wolframalpha.com/v1/simple'
115
+ params = {"appid": self.key,
116
+ "i": query,
117
+ # "background": "F5F5F5",
118
+ "layout": "labelbar",
119
+ "units": units}
120
+ path = join(tempfile.gettempdir(), query.replace(" ", "_") + ".gif")
121
+ if not isfile(path):
122
+ image = requests.get(url, params=params).content
123
+ with open(path, "wb") as f:
124
+ f.write(image)
125
+ return path
126
+
127
+
128
+ class WolframAlphaRetrievalEngine(RetrievalEngine):
129
+ def __init__(self, config=None, translator: Optional[LanguageTranslator] = None):
130
+ super().__init__(config=config)
131
+ self.api = WolframAlphaApi(key=self.config.get("appid") or "Y7R353-9HQAAL8KKA")
132
+ self.translator: Optional[LanguageTranslator] = translator
133
+ if not translator:
134
+ self._load_translator()
135
+
136
+ def _load_translator(self):
137
+ lang_cfg = Configuration().get("language", {})
138
+ plug_id = self.config.get("translate_plugin") or lang_cfg.get("translation_module", "ovos-translate-plugin-server")
139
+ clazz = load_tx_plugin(plug_id)
140
+ if clazz is None:
141
+ LOG.error(f"Translation plugin not loaded '{plug_id}': only english queries will be answered")
142
+ else:
143
+ self.translator = clazz(config=lang_cfg.get(plug_id, {}))
144
+ LOG.debug(f"Loaded translation plugin: '{plug_id}'")
145
+
146
+ def query(self, query: str, lang: Optional[str] = None, k: int = 3) -> List[Tuple[str, float]]:
147
+ """
148
+ Searches Wolfram Alpha for a spoken answer.
149
+
150
+ Args:
151
+ query: The search string.
152
+ lang: BCP-47 language code.
153
+ k: Maximum number of results (unused; Wolfram returns one answer).
154
+
155
+ Returns:
156
+ List of (content, score) tuples, or empty list if no answer.
157
+ """
158
+ units = self.config.get("units") or Configuration().get("system_unit", "metric")
159
+ answer = self.get_spoken_answer(query, lang, units)
160
+ if answer:
161
+ # TODO - use query recognizer to get a confidence level
162
+ return [(answer, 0.9)]
163
+ return []
164
+
165
+ ################################
166
+ # helpers to parse api results
167
+ ################################
168
+ def get_image(self, query: str,
169
+ lang: Optional[str] = None,
170
+ units: Optional[str] = None):
171
+ """Return path to a cached image result for the query."""
172
+ if lang != "en":
173
+ query = self.translator.translate(query, target="en", source=lang)
174
+ units = units or Configuration().get("system_unit", "metric")
175
+ return self.api.get_image(query, units=units)
176
+
177
+ def get_spoken_answer(self, query: str,
178
+ lang: Optional[str] = None,
179
+ units: Optional[str] = None):
180
+ """Return a single natural-language sentence answering the query."""
181
+ lang = lang.split("-")[0].lower()
182
+ if lang != "en":
183
+ query = self.translator.translate(query, target="en", source=lang)
184
+ units = units or Configuration().get("system_unit", "metric")
185
+ answer = self.api.spoken(query, units=units)
186
+ bad_answers = ["no spoken result available",
187
+ "wolfram alpha did not understand your input"]
188
+ if answer.lower().strip() in bad_answers:
189
+ return None
190
+ if lang != "en":
191
+ answer = self.translator.translate(answer, target=lang, source="en")
192
+ return answer
193
+
194
+ def get_expanded_answer(self, query,
195
+ lang: Optional[str] = None,
196
+ units: Optional[str] = None):
197
+ """Return a list of structured result pods from the Full Results API."""
198
+ if lang != "en":
199
+ query = self.translator.translate(query, target="en", source=lang)
200
+ data = self.api.full_results(query, units=units)
201
+ skip = ['Input interpretation', 'Interpretation',
202
+ 'Result', 'Value', 'Image']
203
+ steps = []
204
+
205
+ for pod in data['queryresult'].get('pods', []):
206
+ title = pod["title"]
207
+ if title in skip:
208
+ continue
209
+
210
+ for sub in pod["subpods"]:
211
+ subpod = {"title": title}
212
+ summary = sub["img"]["alt"]
213
+ subtitle = sub.get("title") or sub["img"]["title"]
214
+ if subtitle and subtitle != summary:
215
+ subpod["title"] = subtitle
216
+
217
+ if summary == title:
218
+ # it's an image result
219
+ subpod["img"] = sub["img"]["src"]
220
+ elif summary.startswith("(") and summary.endswith(")"):
221
+ continue
222
+ else:
223
+ subpod["summary"] = summary
224
+ steps.append(subpod)
225
+
226
+ # do any extra processing here
227
+ prev = ""
228
+ for idx, step in enumerate(steps):
229
+ # merge steps
230
+ if step["title"] == prev:
231
+ summary = steps[idx - 1]["summary"] + "\n" + step["summary"]
232
+ steps[idx]["summary"] = summary
233
+ steps[idx]["img"] = step.get("img") or steps[idx - 1].get("img")
234
+ steps[idx - 1] = None
235
+ elif step.get("summary") and step["title"]:
236
+ # inject title in speech, eg we do not want wolfram to just read family names without context
237
+ steps[idx]["summary"] = step["title"] + ".\n" + step["summary"]
238
+
239
+ # normalize summary
240
+ if step.get("summary"):
241
+ steps[idx]["summary"] = self.make_speakable(steps[idx]["summary"])
242
+
243
+ if lang != "en":
244
+ steps[idx]["title"] = self.translator.translate(steps[idx]["title"], target=lang, source="en")
245
+ if step.get("summary"):
246
+ steps[idx]["summary"] = self.translator.translate(steps[idx]["summary"], target=lang, source="en")
247
+
248
+ prev = step["title"]
249
+ return [s for s in steps if s]
250
+
251
+ @staticmethod
252
+ def make_speakable(summary: str):
253
+ """Normalise a Wolfram result string for text-to-speech."""
254
+ # let's remove unwanted data from parantheses
255
+ # - many results have (human: XX unit) ref values, remove them
256
+ if "(human: " in summary:
257
+ splits = summary.split("(human: ")
258
+ for idx, s in enumerate(splits):
259
+ splits[idx] = ")".join(s.split(")")[1:])
260
+ summary = " ".join(splits)
261
+
262
+ # remove duplicated units in text
263
+ # TODO probably there's a lot more to add here....
264
+ replaces = {
265
+ "cm (centimeters)": "centimeters",
266
+ "cm³ (cubic centimeters)": "cubic centimeters",
267
+ "cm² (square centimeters)": "square centimeters",
268
+ "mm (millimeters)": "millimeters",
269
+ "mm² (square millimeters)": "square millimeters",
270
+ "mm³ (cubic millimeters)": "cubic millimeters",
271
+ "kg (kilograms)": "kilograms",
272
+ "kHz (kilohertz)": "kilohertz",
273
+ "ns (nanoseconds)": "nanoseconds",
274
+ "µs (microseconds)": "microseconds",
275
+ "m/s (meters per second)": "meters per second",
276
+ "km/s (kilometers per second)": "kilometers per second",
277
+ "mi/s (miles per second)": "miles per second",
278
+ "mph (miles per hour)": "miles per hour",
279
+ "ª (degrees)": " degrees"
280
+ }
281
+ for k, v in replaces.items():
282
+ summary = summary.replace(k, v)
283
+
284
+ # replace units, only if they are individual words
285
+ units = {
286
+ "cm": "centimeters",
287
+ "cm³": "cubic centimeters",
288
+ "cm²": "square centimeters",
289
+ "mm": "millimeters",
290
+ "mm²": "square millimeters",
291
+ "mm³": "cubic millimeters",
292
+ "kg": "kilograms",
293
+ "kHz": "kilohertz",
294
+ "ns": "nanoseconds",
295
+ "µs": "microseconds",
296
+ "m/s": "meters per second",
297
+ "km/s": "kilometers per second",
298
+ "mi/s": "miles per second",
299
+ "mph": "miles per hour"
300
+ }
301
+ words = [w if w not in units else units[w]
302
+ for w in summary.split(" ")]
303
+ summary = " ".join(words)
304
+ return rm_parentheses(summary)
305
+
306
+
307
+ class SearchWolframAlphaArgs(ToolArguments):
308
+ query: str = Field(..., description="The natural language or mathematical query to look up on Wolfram Alpha. Convert conversational phrasing to concise keywords (e.g. 'France population' not 'how many people live in France'). Always send in English.")
309
+ units: str = Field("metric", description="Unit system for the answer: 'metric' or 'imperial'.")
310
+
311
+
312
+ class SearchWolframAlphaOutput(ToolOutput):
313
+ result: str = Field(..., description="The LLM-optimised Wolfram Alpha answer for the query.")
314
+
315
+
316
+ class WolframAlphaToolbox(ToolBox):
317
+ toolbox_id = "ovos-wolfram-alpha-tools"
318
+
319
+ def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
320
+ """
321
+ Initialise the toolbox.
322
+
323
+ Args:
324
+ config: Plugin configuration dict.
325
+ """
326
+ self.config = config or {}
327
+ self.api = WolframAlphaApi(key=self.config.get("appid") or "Y7R353-9HQAAL8KKA")
328
+ super().__init__(toolbox_id=self.toolbox_id)
329
+
330
+ def search_wolfram(self, args: SearchWolframAlphaArgs) -> SearchWolframAlphaOutput:
331
+ """Query Wolfram Alpha and return the LLM-optimised answer."""
332
+ return SearchWolframAlphaOutput(
333
+ result=self.api.llm(query=args.query, units=args.units)
334
+ )
335
+
336
+ def discover_tools(self) -> List[AgentTool]:
337
+ """
338
+ Abstract method to be implemented by concrete ToolBox plugins.
339
+
340
+ This method must define and return the list of AgentTools provided by this plugin.
341
+ The implementation should be idempotent (safe to call multiple times).
342
+
343
+ Returns:
344
+ A list of instantiated AgentTool objects.
345
+ """
346
+ return [
347
+ AgentTool(
348
+ name="search_wolfram_alpha",
349
+ description=(
350
+ "Query Wolfram Alpha for factual answers: maths, science, unit conversions, "
351
+ "geography, history, and more. Always send queries in English as concise keywords."
352
+ ),
353
+ argument_schema=SearchWolframAlphaArgs,
354
+ output_schema=SearchWolframAlphaOutput,
355
+ tool_call=self.search_wolfram,
356
+ )
357
+ ]
358
+
359
+
360
+ if __name__ == "__main__":
361
+ s = WolframAlphaRetrievalEngine()
362
+ print(s.api.spoken("Who is the author of The Fault in our Stars"))
@@ -0,0 +1,8 @@
1
+ # START_VERSION_BLOCK
2
+ VERSION_MAJOR = 1
3
+ VERSION_MINOR = 0
4
+ VERSION_BUILD = 0
5
+ VERSION_ALPHA = 1
6
+ # END_VERSION_BLOCK
7
+
8
+ __version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "")
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.4
2
+ Name: ovos-wolfram-alpha-plugin
3
+ Version: 1.0.0a1
4
+ Summary: ovos wolfram-alpha plugin
5
+ Author-email: JarbasAi <jarbasai@mailfence.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/OpenVoiceOS/ovos-wolfram-alpha-plugin
8
+ Keywords: ovos,plugin,wolfram
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: ovos-utils<1.0.0,>=0.7.0
11
+ Requires-Dist: ovos-plugin-manager<3.0.0,>=2.3.0a1
12
+ Requires-Dist: ovos-config<2.0.0,>=0.0.12
13
+ Requires-Dist: requests>=2.0.0
14
+ Requires-Dist: pydantic<3.0.0,>=2.0.0
15
+
16
+ # ovos-wolfram-alpha-plugin
17
+
18
+ [![PyPI](https://img.shields.io/pypi/v/ovos-wolfram-alpha-plugin)](https://pypi.org/project/ovos-wolfram-alpha-plugin/)
19
+ [![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
20
+ [![Python](https://img.shields.io/badge/python-%3E%3D3.10-blue)](https://www.python.org/)
21
+
22
+ [Wolfram Alpha](https://www.wolframalpha.com/) integration for [OpenVoiceOS](https://openvoiceos.org). Provides a **retrieval engine** for RAG pipelines and an **agent toolbox** for tool-using agents, both as standard OPM plugins.
23
+
24
+ Wolfram Alpha excels at questions with a single definitive answer: maths, unit conversions, scientific constants, chemical properties, astronomy, nutrition, geography, and historical dates. It is not a search engine — it computes answers from curated data.
25
+
26
+ An [API key](https://products.wolframalpha.com/api/) is required. A demo key is bundled for development but is rate-limited and should not be used in production.
27
+
28
+ ---
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install ovos-wolfram-alpha-plugin
34
+ ```
35
+
36
+ ---
37
+
38
+ ## OPM Entry Points
39
+
40
+ | Entry point | Class | Use case |
41
+ |---|---|---|
42
+ | `opm.agents.retrieval` — `ovos-wolfram-alpha-solver` | `WolframAlphaRetrievalEngine` | RAG — returns `(answer, score)` tuples |
43
+ | `opm.agents.toolbox` — `ovos-wolfram-alpha-tools` | `WolframAlphaToolbox` | Agent tool use — exposes `search_wolfram_alpha` |
44
+
45
+ ---
46
+
47
+ ## Retrieval Engine
48
+
49
+ `WolframAlphaRetrievalEngine` implements the `RetrievalEngine` OPM interface. It calls the Wolfram Alpha spoken-answer endpoint and handles non-English queries by translating them to English before the request and back after.
50
+
51
+ ```python
52
+ from ovos_wolfram_alpha_plugin import WolframAlphaRetrievalEngine
53
+
54
+ engine = WolframAlphaRetrievalEngine(config={"appid": "YOUR-KEY"})
55
+
56
+ # Maths & conversions
57
+ engine.get_spoken_answer("integral of x^2 sin(x)", lang="en")
58
+ # "x^2 (-cos(x)) + 2 x sin(x) + 2 cos(x) + constant"
59
+
60
+ engine.get_spoken_answer("100 miles in kilometers", lang="en")
61
+ # "160.934 kilometers"
62
+
63
+ engine.get_spoken_answer("1000 USD in EUR", lang="en")
64
+ # "approximately 923 euros" (live rate)
65
+
66
+ # Science & constants
67
+ engine.get_spoken_answer("speed of light", lang="en")
68
+ # "about 2.998 × 10^8 meters per second"
69
+
70
+ engine.get_spoken_answer("boiling point of ethanol", lang="en")
71
+ # "78.37 degrees Celsius"
72
+
73
+ engine.get_spoken_answer("distance from Earth to Mars", lang="en")
74
+ # "currently about 1.69 AU" (live ephemeris)
75
+
76
+ # Factual lookups
77
+ engine.get_spoken_answer("population of Brazil", lang="en")
78
+ # "approximately 215.3 million people"
79
+
80
+ engine.get_spoken_answer("calories in 100g of almonds", lang="en")
81
+ # "579 kilocalories"
82
+
83
+ engine.get_spoken_answer("when was the Eiffel Tower built", lang="en")
84
+ # "construction was from January 28, 1887 to March 31, 1889"
85
+
86
+ # Non-English — translated automatically
87
+ engine.get_spoken_answer("massa do Sol", lang="pt")
88
+ # "aproximadamente 1,989 × 10^30 kg"
89
+
90
+ # Image result — returns a local file path to a Wolfram visual
91
+ engine.get_image("benzene molecular structure", lang="en")
92
+
93
+ # Full structured pod results — list of {"title", "summary"} dicts
94
+ for pod in engine.get_expanded_answer("Neptune", lang="en"):
95
+ print(pod["title"], "—", pod.get("summary", pod.get("img")))
96
+ # "Orbital period — 164.8 years"
97
+ # "Surface gravity — 11.15 m/s²"
98
+ # ...
99
+
100
+ # RAG interface: List[Tuple[str, float]] (answer, score)
101
+ results = engine.query("half-life of carbon-14", lang="en")
102
+ # [("5730 years", 0.9)]
103
+ ```
104
+
105
+ ### Translation
106
+
107
+ Non-English queries require a translation plugin. Configure it by passing `translate_plugin` in the config:
108
+
109
+ ```python
110
+ engine = WolframAlphaRetrievalEngine(config={
111
+ "appid": "YOUR-KEY",
112
+ "translate_plugin": "ovos-translate-plugin-server",
113
+ })
114
+ ```
115
+
116
+ If no translation plugin is available, only English queries are answered.
117
+
118
+ ---
119
+
120
+ ## Agent Toolbox
121
+
122
+ `WolframAlphaToolbox` exposes a single `search_wolfram_alpha` tool that any OPM-compatible agent loop (e.g. [ovos-agentic-loop](https://github.com/OpenVoiceOS/ovos-agentic-loop)) can discover and call. The tool uses the LLM-optimised Wolfram endpoint, which returns a more structured answer than the spoken endpoint.
123
+
124
+ ### Persona JSON
125
+
126
+ Reference the toolbox by its entry point name inside any agentic persona. Pass a `system_prompt` to the brain plugin so the LLM knows how to query Wolfram correctly:
127
+
128
+ ```json
129
+ {
130
+ "name": "Wolfram Alpha",
131
+ "solvers": ["ovos-react-loop"],
132
+ "ovos-react-loop": {
133
+ "brain": "ovos-chat-openai-plugin",
134
+ "toolboxes": ["ovos-wolfram-alpha-tools"],
135
+ "ovos-chat-openai-plugin": {
136
+ "api_url": "http://localhost:11434/v1/chat/completions",
137
+ "system_prompt": "You have access to Wolfram Alpha. Use it for maths, science, unit conversions, and factual questions with a definite answer. Always send queries in English as concise keywords (e.g. 'France population', not 'how many people live in France'). Use the exponent notation 6*10^14, never 6e14. If the result is not relevant, retry with a more specific query rather than rephrasing."
138
+ }
139
+ }
140
+ }
141
+ ```
142
+
143
+ > 💡 The [official LLM API docs](https://products.wolframalpha.com/llm-api/documentation) have more tips on writing effective Wolfram system prompts.
144
+
145
+ ### Direct usage
146
+
147
+ ```python
148
+ from ovos_wolfram_alpha_plugin import WolframAlphaToolbox, SearchWolframAlphaArgs
149
+
150
+ tb = WolframAlphaToolbox(config={"appid": "YOUR-KEY"})
151
+
152
+ tools = tb.discover_tools()
153
+ # [AgentTool(name="search_wolfram_alpha", ...)]
154
+
155
+ output = tb.search_wolfram(SearchWolframAlphaArgs(query="France population", units="metric"))
156
+ print(output.result)
157
+ ```
158
+
159
+ ---
160
+
161
+ ## License
162
+
163
+ Apache 2.0 — see [LICENSE](LICENSE).
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ ovos_wolfram_alpha_plugin/__init__.py
4
+ ovos_wolfram_alpha_plugin/version.py
5
+ ovos_wolfram_alpha_plugin.egg-info/PKG-INFO
6
+ ovos_wolfram_alpha_plugin.egg-info/SOURCES.txt
7
+ ovos_wolfram_alpha_plugin.egg-info/dependency_links.txt
8
+ ovos_wolfram_alpha_plugin.egg-info/entry_points.txt
9
+ ovos_wolfram_alpha_plugin.egg-info/requires.txt
10
+ ovos_wolfram_alpha_plugin.egg-info/top_level.txt
11
+ tests/test_wolfram_alpha.py
@@ -0,0 +1,5 @@
1
+ [opm.agents.retrieval]
2
+ ovos-wolfram-alpha-plugin = ovos_wolfram_alpha_plugin:WolframAlphaRetrievalEngine
3
+
4
+ [opm.agents.toolbox]
5
+ ovos-wolfram-alpha-tools = ovos_wolfram_alpha_plugin:WolframAlphaToolbox
@@ -0,0 +1,5 @@
1
+ ovos-utils<1.0.0,>=0.7.0
2
+ ovos-plugin-manager<3.0.0,>=2.3.0a1
3
+ ovos-config<2.0.0,>=0.0.12
4
+ requests>=2.0.0
5
+ pydantic<3.0.0,>=2.0.0
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=42", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ovos-wolfram-alpha-plugin"
7
+ dynamic = ["version"]
8
+ description = "ovos wolfram-alpha plugin"
9
+ readme = "README.md"
10
+ license = {text = "Apache-2.0"}
11
+ authors = [
12
+ {name = "JarbasAi", email = "jarbasai@mailfence.com"}
13
+ ]
14
+ keywords = ["ovos", "plugin", "wolfram"]
15
+ dependencies = [
16
+ "ovos-utils>=0.7.0,<1.0.0",
17
+ "ovos-plugin-manager>=2.3.0a1,<3.0.0",
18
+ "ovos-config>=0.0.12,<2.0.0",
19
+ "requests>=2.0.0",
20
+ "pydantic>=2.0.0,<3.0.0"
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/OpenVoiceOS/ovos-wolfram-alpha-plugin"
25
+
26
+ [project.entry-points."opm.agents.retrieval"]
27
+ "ovos-wolfram-alpha-plugin" = "ovos_wolfram_alpha_plugin:WolframAlphaRetrievalEngine"
28
+
29
+ [project.entry-points."opm.agents.toolbox"]
30
+ "ovos-wolfram-alpha-tools" = "ovos_wolfram_alpha_plugin:WolframAlphaToolbox"
31
+
32
+ [tool.setuptools.dynamic]
33
+ version = {attr = "ovos_wolfram_alpha_plugin.version.__version__"}
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["."]
37
+ include = ["ovos_wolfram_alpha_plugin*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,196 @@
1
+ """
2
+ Unit tests for ovos-wolfram-alpha-plugin.
3
+
4
+ All network calls and OVOS config are mocked — no API key or daemon required.
5
+ """
6
+ import unittest
7
+ from unittest.mock import MagicMock, patch, mock_open
8
+
9
+ from ovos_wolfram_alpha_plugin import (
10
+ WolframAlphaApi,
11
+ WolframAlphaRetrievalEngine,
12
+ WolframAlphaToolbox,
13
+ SearchWolframAlphaArgs,
14
+ SearchWolframAlphaOutput,
15
+ )
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # WolframAlphaApi
20
+ # ---------------------------------------------------------------------------
21
+
22
+ class TestWolframAlphaApi(unittest.TestCase):
23
+
24
+ def setUp(self):
25
+ self.api = WolframAlphaApi(key="TEST-KEY")
26
+
27
+ def test_key_stored(self):
28
+ self.assertEqual(self.api.key, "TEST-KEY")
29
+
30
+ def test_default_key_used_when_falsy(self):
31
+ api = WolframAlphaApi(key=None)
32
+ self.assertEqual(api.key, "Y7R353-9HQAAL8KKA")
33
+
34
+ def _mock_get(self, text="answer"):
35
+ m = MagicMock()
36
+ m.text = text
37
+ m.content = b"imagedata"
38
+ return m
39
+
40
+ def test_spoken_returns_text(self):
41
+ with patch("ovos_wolfram_alpha_plugin.requests.get") as mock_get:
42
+ mock_get.return_value = self._mock_get("The speed of light is 3×10^8 m/s.")
43
+ result = self.api.spoken("speed of light", lat_lon=(0, 0))
44
+ self.assertEqual(result, "The speed of light is 3×10^8 m/s.")
45
+
46
+ def test_llm_returns_text(self):
47
+ with patch("ovos_wolfram_alpha_plugin.requests.get") as mock_get:
48
+ mock_get.return_value = self._mock_get("42")
49
+ result = self.api.llm("meaning of life", lat_lon=(0, 0))
50
+ self.assertEqual(result, "42")
51
+
52
+ def test_full_results_returns_json(self):
53
+ payload = {"queryresult": {"pods": []}}
54
+ with patch("ovos_wolfram_alpha_plugin.requests.get") as mock_get:
55
+ mock_get.return_value.json.return_value = payload
56
+ result = self.api.full_results("mercury", lat_lon=(0, 0))
57
+ self.assertEqual(result, payload)
58
+
59
+ def test_get_image_writes_file_on_miss(self):
60
+ with patch("ovos_wolfram_alpha_plugin.isfile", return_value=False), \
61
+ patch("ovos_wolfram_alpha_plugin.requests.get") as mock_get, \
62
+ patch("builtins.open", mock_open()) as mocked_open:
63
+ mock_get.return_value.content = b"gif"
64
+ path = self.api.get_image("mercury")
65
+ self.assertTrue(path.endswith(".gif"))
66
+ mocked_open.assert_called_once()
67
+
68
+ def test_get_image_skips_download_on_hit(self):
69
+ with patch("ovos_wolfram_alpha_plugin.isfile", return_value=True), \
70
+ patch("ovos_wolfram_alpha_plugin.requests.get") as mock_get:
71
+ self.api.get_image("mercury")
72
+ mock_get.assert_not_called()
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # WolframAlphaRetrievalEngine
77
+ # ---------------------------------------------------------------------------
78
+
79
+ class TestWolframAlphaRetrievalEngine(unittest.TestCase):
80
+
81
+ def _make_engine(self):
82
+ with patch("ovos_wolfram_alpha_plugin.load_tx_plugin", return_value=None), \
83
+ patch("ovos_wolfram_alpha_plugin.Configuration", return_value={}):
84
+ return WolframAlphaRetrievalEngine(config={"appid": "TEST"})
85
+
86
+ def test_query_returns_answer_and_score(self):
87
+ engine = self._make_engine()
88
+ engine.get_spoken_answer = MagicMock(return_value="Venus is the second planet.")
89
+ with patch("ovos_wolfram_alpha_plugin.Configuration", return_value={}):
90
+ result = engine.query("venus", lang="en")
91
+ self.assertEqual(result, [("Venus is the second planet.", 0.9)])
92
+
93
+ def test_query_returns_empty_when_no_answer(self):
94
+ engine = self._make_engine()
95
+ engine.get_spoken_answer = MagicMock(return_value=None)
96
+ with patch("ovos_wolfram_alpha_plugin.Configuration", return_value={}):
97
+ result = engine.query("xyzzy", lang="en")
98
+ self.assertEqual(result, [])
99
+
100
+ def test_get_spoken_answer_filters_bad_responses(self):
101
+ engine = self._make_engine()
102
+ engine.api = MagicMock()
103
+ engine.api.spoken.return_value = "Wolfram Alpha did not understand your input"
104
+ with patch("ovos_wolfram_alpha_plugin.Configuration", return_value={}):
105
+ result = engine.get_spoken_answer("gibberish", lang="en")
106
+ self.assertIsNone(result)
107
+
108
+ def test_get_spoken_answer_returns_answer_for_english(self):
109
+ engine = self._make_engine()
110
+ engine.api = MagicMock()
111
+ engine.api.spoken.return_value = "Mercury is the smallest planet."
112
+ with patch("ovos_wolfram_alpha_plugin.Configuration", return_value={}):
113
+ result = engine.get_spoken_answer("mercury", lang="en")
114
+ self.assertEqual(result, "Mercury is the smallest planet.")
115
+
116
+ def test_get_spoken_answer_translates_non_english(self):
117
+ engine = self._make_engine()
118
+ engine.translator = MagicMock()
119
+ engine.translator.translate.side_effect = lambda q, target, source: f"[{target}]{q}"
120
+ engine.api = MagicMock()
121
+ engine.api.spoken.return_value = "Mercury is a planet."
122
+ with patch("ovos_wolfram_alpha_plugin.Configuration", return_value={}):
123
+ result = engine.get_spoken_answer("mercúrio", lang="pt")
124
+ self.assertIn("pt", result)
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # make_speakable
129
+ # ---------------------------------------------------------------------------
130
+
131
+ class TestMakeSpeakable(unittest.TestCase):
132
+
133
+ def test_removes_human_parenthetical(self):
134
+ text = "The height is 1.8 m (human: 5 ft 11 in)"
135
+ result = WolframAlphaRetrievalEngine.make_speakable(text)
136
+ self.assertNotIn("human:", result)
137
+
138
+ def test_expands_unit_abbreviation(self):
139
+ result = WolframAlphaRetrievalEngine.make_speakable("mass is 5 kg")
140
+ self.assertIn("kilograms", result)
141
+
142
+ def test_expands_cm_unit(self):
143
+ result = WolframAlphaRetrievalEngine.make_speakable("height 180 cm")
144
+ self.assertIn("centimeters", result)
145
+
146
+ def test_noop_on_plain_text(self):
147
+ text = "The answer is forty-two."
148
+ result = WolframAlphaRetrievalEngine.make_speakable(text)
149
+ self.assertEqual(result, text)
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # WolframAlphaToolbox
154
+ # ---------------------------------------------------------------------------
155
+
156
+ class TestWolframAlphaToolbox(unittest.TestCase):
157
+
158
+ def _make_toolbox(self):
159
+ with patch("ovos_wolfram_alpha_plugin.WolframAlphaApi"):
160
+ return WolframAlphaToolbox(config={"appid": "TEST"})
161
+
162
+ def test_discover_tools_returns_one_tool(self):
163
+ tb = self._make_toolbox()
164
+ tools = tb.discover_tools()
165
+ self.assertEqual(len(tools), 1)
166
+ self.assertEqual(tools[0].name, "search_wolfram_alpha")
167
+
168
+ def test_search_wolfram_returns_output(self):
169
+ tb = self._make_toolbox()
170
+ tb.api = MagicMock()
171
+ tb.api.llm.return_value = "42"
172
+ args = SearchWolframAlphaArgs(query="meaning of life", units="metric")
173
+ result = tb.search_wolfram(args)
174
+ self.assertIsInstance(result, SearchWolframAlphaOutput)
175
+ self.assertEqual(result.result, "42")
176
+
177
+ def test_toolbox_id(self):
178
+ self.assertEqual(WolframAlphaToolbox.toolbox_id, "ovos-wolfram-alpha-tools")
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Plugin loading
183
+ # ---------------------------------------------------------------------------
184
+
185
+ class TestPluginLoading(unittest.TestCase):
186
+
187
+ def test_imports(self):
188
+ from ovos_wolfram_alpha_plugin import (
189
+ WolframAlphaApi,
190
+ WolframAlphaRetrievalEngine,
191
+ WolframAlphaToolbox,
192
+ )
193
+
194
+
195
+ if __name__ == "__main__":
196
+ unittest.main()