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.
- ovos_wolfram_alpha_plugin-1.0.0a1/PKG-INFO +163 -0
- ovos_wolfram_alpha_plugin-1.0.0a1/README.md +148 -0
- ovos_wolfram_alpha_plugin-1.0.0a1/ovos_wolfram_alpha_plugin/__init__.py +362 -0
- ovos_wolfram_alpha_plugin-1.0.0a1/ovos_wolfram_alpha_plugin/version.py +8 -0
- ovos_wolfram_alpha_plugin-1.0.0a1/ovos_wolfram_alpha_plugin.egg-info/PKG-INFO +163 -0
- ovos_wolfram_alpha_plugin-1.0.0a1/ovos_wolfram_alpha_plugin.egg-info/SOURCES.txt +11 -0
- ovos_wolfram_alpha_plugin-1.0.0a1/ovos_wolfram_alpha_plugin.egg-info/dependency_links.txt +1 -0
- ovos_wolfram_alpha_plugin-1.0.0a1/ovos_wolfram_alpha_plugin.egg-info/entry_points.txt +5 -0
- ovos_wolfram_alpha_plugin-1.0.0a1/ovos_wolfram_alpha_plugin.egg-info/requires.txt +5 -0
- ovos_wolfram_alpha_plugin-1.0.0a1/ovos_wolfram_alpha_plugin.egg-info/top_level.txt +1 -0
- ovos_wolfram_alpha_plugin-1.0.0a1/pyproject.toml +37 -0
- ovos_wolfram_alpha_plugin-1.0.0a1/setup.cfg +4 -0
- ovos_wolfram_alpha_plugin-1.0.0a1/tests/test_wolfram_alpha.py +196 -0
|
@@ -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
|
+
[](https://pypi.org/project/ovos-wolfram-alpha-plugin/)
|
|
19
|
+
[](LICENSE)
|
|
20
|
+
[](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
|
+
[](https://pypi.org/project/ovos-wolfram-alpha-plugin/)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](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,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
|
+
[](https://pypi.org/project/ovos-wolfram-alpha-plugin/)
|
|
19
|
+
[](LICENSE)
|
|
20
|
+
[](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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ovos_wolfram_alpha_plugin
|
|
@@ -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,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()
|