aviary.hotpotqa 0.1.0__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.
- aviary_hotpotqa-0.1.0/PKG-INFO +26 -0
- aviary_hotpotqa-0.1.0/README.md +12 -0
- aviary_hotpotqa-0.1.0/pyproject.toml +31 -0
- aviary_hotpotqa-0.1.0/setup.cfg +4 -0
- aviary_hotpotqa-0.1.0/src/aviary/hotpotqa/__init__.py +3 -0
- aviary_hotpotqa-0.1.0/src/aviary/hotpotqa/env.py +559 -0
- aviary_hotpotqa-0.1.0/src/aviary/hotpotqa/py.typed +0 -0
- aviary_hotpotqa-0.1.0/src/aviary/hotpotqa/version.py +16 -0
- aviary_hotpotqa-0.1.0/src/aviary.hotpotqa.egg-info/PKG-INFO +26 -0
- aviary_hotpotqa-0.1.0/src/aviary.hotpotqa.egg-info/SOURCES.txt +12 -0
- aviary_hotpotqa-0.1.0/src/aviary.hotpotqa.egg-info/dependency_links.txt +1 -0
- aviary_hotpotqa-0.1.0/src/aviary.hotpotqa.egg-info/requires.txt +6 -0
- aviary_hotpotqa-0.1.0/src/aviary.hotpotqa.egg-info/top_level.txt +1 -0
- aviary_hotpotqa-0.1.0/tests/test_hotpotqa_env.py +22 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: aviary.hotpotqa
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HotPotQA environment implemented with aviary
|
|
5
|
+
Author-email: FutureHouse technical staff <hello@futurehouse.org>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: beautifulsoup4
|
|
9
|
+
Requires-Dist: datasets
|
|
10
|
+
Requires-Dist: fhaviary
|
|
11
|
+
Requires-Dist: httpx
|
|
12
|
+
Requires-Dist: pydantic~=2.0
|
|
13
|
+
Requires-Dist: tenacity
|
|
14
|
+
|
|
15
|
+
# aviary.hotpotqa
|
|
16
|
+
|
|
17
|
+
HotPotQA environment implemented with aviary.
|
|
18
|
+
|
|
19
|
+
## References
|
|
20
|
+
|
|
21
|
+
[1] Yang et al. [HotpotQA: A Dataset for Diverse,
|
|
22
|
+
Explainable Multi-Hop Question Answering](https://aclanthology.org/D18-1259/). EMNLP, 2018.
|
|
23
|
+
|
|
24
|
+
[2] Yao et al.,
|
|
25
|
+
[ReAct: Synergizing Reasoning and Acting in Language Models](https://openreview.net/forum?id=WE_vluYUL-X).
|
|
26
|
+
In The Eleventh International Conference on Learning Representations. 2023
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# aviary.hotpotqa
|
|
2
|
+
|
|
3
|
+
HotPotQA environment implemented with aviary.
|
|
4
|
+
|
|
5
|
+
## References
|
|
6
|
+
|
|
7
|
+
[1] Yang et al. [HotpotQA: A Dataset for Diverse,
|
|
8
|
+
Explainable Multi-Hop Question Answering](https://aclanthology.org/D18-1259/). EMNLP, 2018.
|
|
9
|
+
|
|
10
|
+
[2] Yao et al.,
|
|
11
|
+
[ReAct: Synergizing Reasoning and Acting in Language Models](https://openreview.net/forum?id=WE_vluYUL-X).
|
|
12
|
+
In The Eleventh International Conference on Learning Representations. 2023
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
build-backend = "setuptools.build_meta"
|
|
3
|
+
requires = ["setuptools>=64", "setuptools_scm>=8"]
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
authors = [
|
|
7
|
+
{email = "hello@futurehouse.org", name = "FutureHouse technical staff"},
|
|
8
|
+
]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"beautifulsoup4",
|
|
11
|
+
"datasets",
|
|
12
|
+
"fhaviary",
|
|
13
|
+
"httpx",
|
|
14
|
+
"pydantic~=2.0",
|
|
15
|
+
"tenacity",
|
|
16
|
+
]
|
|
17
|
+
description = "HotPotQA environment implemented with aviary"
|
|
18
|
+
dynamic = ["version"]
|
|
19
|
+
name = "aviary.hotpotqa"
|
|
20
|
+
readme = "README.md"
|
|
21
|
+
requires-python = ">=3.11"
|
|
22
|
+
|
|
23
|
+
[tool.ruff]
|
|
24
|
+
extend = "../../pyproject.toml"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools_scm]
|
|
30
|
+
root = "../.."
|
|
31
|
+
version_file = "src/aviary/hotpotqa/version.py"
|
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
"""HotPotQA environment for aviary agents.
|
|
2
|
+
|
|
3
|
+
Implements the HotPotQA multihop question-answering environment from:
|
|
4
|
+
|
|
5
|
+
Yao et al., ReAct: Synergizing Reasoning and Acting in Language Models.
|
|
6
|
+
In The Eleventh International Conference on Learning Representations. 2023
|
|
7
|
+
|
|
8
|
+
Agents in the HotPotQA environment can perform searches and lookups to retrieve specific information.
|
|
9
|
+
The environment supports 3 tools:
|
|
10
|
+
|
|
11
|
+
Search[entity]: Search for a specific entity on Wikipedia and return relevant information.
|
|
12
|
+
Lookup[keyword]: Find and return the next sentence containing the keyword in the current passage.
|
|
13
|
+
Finish[answer]: Submit the final answer to the question and conclude the task.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import random
|
|
18
|
+
import re
|
|
19
|
+
import string
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from enum import StrEnum
|
|
22
|
+
from typing import Any, ClassVar, cast
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
from bs4 import BeautifulSoup
|
|
26
|
+
from datasets import load_dataset
|
|
27
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
28
|
+
from tenacity import retry, stop_after_attempt, wait_exponential_jitter
|
|
29
|
+
|
|
30
|
+
from aviary.env import Environment, Frame, TaskDataset
|
|
31
|
+
from aviary.message import Message
|
|
32
|
+
from aviary.tools import Tool, ToolRequestMessage, ToolResponseMessage
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Jitter in case we have lots of requests at once, to space them out slightly
|
|
38
|
+
@retry(stop=stop_after_attempt(2), wait=wait_exponential_jitter(initial=1, max=4))
|
|
39
|
+
async def fetch_with_retry(
|
|
40
|
+
client: httpx.AsyncClient, url: str, **kwargs
|
|
41
|
+
) -> httpx.Response:
|
|
42
|
+
response = await client.get(url, **kwargs)
|
|
43
|
+
if response.status_code == httpx.codes.TOO_MANY_REQUESTS:
|
|
44
|
+
response.raise_for_status()
|
|
45
|
+
return response
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class HotPotQAEnvState(BaseModel):
|
|
49
|
+
"""State of the HotPotQA environment."""
|
|
50
|
+
|
|
51
|
+
done: bool = Field(
|
|
52
|
+
default=False,
|
|
53
|
+
description="Flag for being done, automatically set true after many steps.",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
steps: int = Field(default=0, description="Count of environment steps.")
|
|
57
|
+
|
|
58
|
+
reward: float = Field(
|
|
59
|
+
default=0.0, description="Current reward value, reset each environment step."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
answer: str | None = Field(
|
|
63
|
+
default=None,
|
|
64
|
+
description="The answer to the question, or None if not yet answered.",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
last_lookup: str | None = Field(
|
|
68
|
+
default=None, description="The last lookup keyword."
|
|
69
|
+
)
|
|
70
|
+
lookup_results: list[str] = Field(
|
|
71
|
+
default_factory=list, description="Results of the last lookup."
|
|
72
|
+
)
|
|
73
|
+
lookup_index: int = Field(
|
|
74
|
+
default=0, description="Index of the last retrieved lookup result."
|
|
75
|
+
)
|
|
76
|
+
page: str | None = Field(default=None, description="The current Wikipedia page.")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def create_tool(function: Callable, name: str) -> Tool:
|
|
80
|
+
"""Create a Tool object from a function and set its name.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
function: The function to be wrapped by the Tool.
|
|
84
|
+
name: The name to assign to the Tool.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
A Tool object with the specified function and name.
|
|
88
|
+
"""
|
|
89
|
+
tool = Tool.from_function(function)
|
|
90
|
+
tool.info.name = name
|
|
91
|
+
return tool
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def clean_str(p: str) -> str:
|
|
95
|
+
r"""Clean and normalize a given string by encoding and decoding it.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
p: The input string to be cleaned and normalized.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The cleaned and normalized UTF-8 encoded string.
|
|
102
|
+
|
|
103
|
+
Examples:
|
|
104
|
+
>>> clean_str("This is a test string with unicode escape: \\u00e9")
|
|
105
|
+
'This is a test string with unicode escape: é'
|
|
106
|
+
>>> clean_str(r"SBN IT\ICCU\UBO\2771748.") # SEE: https://en.wikipedia.org/wiki/Tricolour_Day
|
|
107
|
+
'SBN IT\\ICCU\\UBO\\2771748.'
|
|
108
|
+
"""
|
|
109
|
+
# Original source:
|
|
110
|
+
# https://github.com/ysymyth/ReAct/blob/6bdb3a1fd38b8188fc7ba4102969fe483df8fdc9/wikienv.py#L10-L11
|
|
111
|
+
try:
|
|
112
|
+
# unicode-escape codec can interpret backslash escapes (e.g., `\n`, `\u1234`)
|
|
113
|
+
return (
|
|
114
|
+
p.encode("latin1").decode("unicode-escape").encode("utf-8").decode("utf-8")
|
|
115
|
+
)
|
|
116
|
+
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
117
|
+
logger.debug(f"Replacing bad characters in input string {p!r}.")
|
|
118
|
+
return p.encode(errors="replace").decode("utf-8", errors="replace")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def normalize_answer(s: str | float) -> str:
|
|
122
|
+
"""Normalize the given answer string by applying several text processing steps.
|
|
123
|
+
|
|
124
|
+
This function processes the input string through a series of transformations to
|
|
125
|
+
normalize it, which includes:
|
|
126
|
+
1. Convert all characters to lowercase.
|
|
127
|
+
2. Remove punctuation from the text.
|
|
128
|
+
3. Remove articles ('a', 'an', 'the') from the text.
|
|
129
|
+
4. Fix extra whitespace by reducing multiple spaces to a single space.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
s: The input answer string to be normalized.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
The normalized answer string.
|
|
136
|
+
|
|
137
|
+
Example:
|
|
138
|
+
>>> normalize_answer("The Quick, Brown Fox!")
|
|
139
|
+
'quick brown fox'
|
|
140
|
+
"""
|
|
141
|
+
# if s is not a string, convert it to a string
|
|
142
|
+
if not isinstance(s, str):
|
|
143
|
+
return str(s)
|
|
144
|
+
|
|
145
|
+
def remove_articles(text: str) -> str:
|
|
146
|
+
"""Remove articles ('a', 'an', 'the') from the text."""
|
|
147
|
+
return re.sub(r"\b(a|an|the)\b", " ", text)
|
|
148
|
+
|
|
149
|
+
def white_space_fix(text: str) -> str:
|
|
150
|
+
"""Reduce multiple spaces to a single space."""
|
|
151
|
+
return " ".join(text.split())
|
|
152
|
+
|
|
153
|
+
def remove_punc(text: str) -> str:
|
|
154
|
+
"""Remove all punctuation from the text."""
|
|
155
|
+
exclude = set(string.punctuation)
|
|
156
|
+
return "".join(ch for ch in text if ch not in exclude)
|
|
157
|
+
|
|
158
|
+
def lower(text: str) -> str:
|
|
159
|
+
"""Convert all characters to lowercase."""
|
|
160
|
+
return text.lower()
|
|
161
|
+
|
|
162
|
+
return white_space_fix(remove_articles(remove_punc(lower(s))))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class HotPotQAEnv(Environment[HotPotQAEnvState]):
|
|
166
|
+
State: ClassVar = HotPotQAEnvState
|
|
167
|
+
wiki_cache: ClassVar[dict[str, str]] = {}
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
question: str,
|
|
172
|
+
correct_answer: str,
|
|
173
|
+
correct_reward: float = 1.0,
|
|
174
|
+
incorrect_reward: float = 0.0,
|
|
175
|
+
tool_failure_reward: float = 0.0,
|
|
176
|
+
proxy: str | None = None,
|
|
177
|
+
):
|
|
178
|
+
super().__init__()
|
|
179
|
+
self.question = question
|
|
180
|
+
self.correct_answer = correct_answer
|
|
181
|
+
self.correct_reward = correct_reward
|
|
182
|
+
self.incorrect_reward = incorrect_reward
|
|
183
|
+
self.tool_failure_reward = tool_failure_reward
|
|
184
|
+
self.proxy = proxy
|
|
185
|
+
|
|
186
|
+
# Title case tool names to match third party demonstration data
|
|
187
|
+
self.tools = [
|
|
188
|
+
create_tool(self.search, "Search"),
|
|
189
|
+
create_tool(self.construct_lookup_list, "Lookup"),
|
|
190
|
+
create_tool(self.finish, "Finish"),
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
def calculate_reward(self, answer: str | None) -> float:
|
|
194
|
+
"""Calculate the reward based on the agent's answer.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
The correct reward if the agent's answer is correct (prediction exactly
|
|
198
|
+
matches ground truth), otherwise the incorrect reward.
|
|
199
|
+
"""
|
|
200
|
+
if answer is None:
|
|
201
|
+
return self.incorrect_reward
|
|
202
|
+
gt = normalize_answer(self.correct_answer)
|
|
203
|
+
pred = normalize_answer(answer)
|
|
204
|
+
return self.correct_reward if pred == gt else self.incorrect_reward
|
|
205
|
+
|
|
206
|
+
async def reset(self) -> tuple[list[Message], list[Tool]]:
|
|
207
|
+
"""Reset the HotPotQA environment to an initial state.
|
|
208
|
+
|
|
209
|
+
This method resets the environment to its initial state, setting up necessary variables and tools
|
|
210
|
+
for the agent to interact with. It prepares the environment for a new episode by initializing
|
|
211
|
+
various attributes and returning the initial observation and tools available for the agent.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
tuple: A tuple containing:
|
|
215
|
+
- list[Message]: The initial observation wrapped in a Message object.
|
|
216
|
+
- list[Tool]: A list of tools (Search, Lookup, and Finish) available for the agent.
|
|
217
|
+
|
|
218
|
+
Example:
|
|
219
|
+
>>> env = HotPotQAEnv()
|
|
220
|
+
>>> initial_obs, tools = env.reset(seed=42, idx=5)
|
|
221
|
+
>>> print(initial_obs)
|
|
222
|
+
[Message(content='Question: <question_text>')]
|
|
223
|
+
>>> print(tools)
|
|
224
|
+
[<Tool: Search>, <Tool: Lookup>, <Tool: Finish>]
|
|
225
|
+
"""
|
|
226
|
+
self.state = self.State()
|
|
227
|
+
return [Message(content=f"Question: {self.question}")], self.tools
|
|
228
|
+
|
|
229
|
+
async def step(
|
|
230
|
+
self, action: ToolRequestMessage
|
|
231
|
+
) -> tuple[list[Message], float, bool, bool]:
|
|
232
|
+
"""Take a step in the environment. Assume only one tool at a time can be called for HotpotQA.
|
|
233
|
+
|
|
234
|
+
This method processes an action message, which can be a tool request, a finish request, or an error message.
|
|
235
|
+
It updates the environment state accordingly and returns the observation and done status.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
action: Action to take.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Tuple[List[ToolResponseMessage], bool]: A tuple containing:
|
|
242
|
+
- list[ToolResponseMessage]: The response message(s) from the executed tool.
|
|
243
|
+
- bool: The done status indicating whether the episode is finished.
|
|
244
|
+
|
|
245
|
+
Example:
|
|
246
|
+
>>> env = HotPotQAEnv()
|
|
247
|
+
>>> action = ToolRequestMessage(
|
|
248
|
+
... tool_calls=[
|
|
249
|
+
... ToolCall(
|
|
250
|
+
... function=ToolCallFunction(name="Search"),
|
|
251
|
+
... arguments={"entity": "Python"},
|
|
252
|
+
... )
|
|
253
|
+
... ]
|
|
254
|
+
... )
|
|
255
|
+
>>> obs, done = await env.step(action)
|
|
256
|
+
>>> print(obs, done)
|
|
257
|
+
[ToolResponseMessage(name='Search', tool_call_id='tool_call_id', content='...')], False
|
|
258
|
+
"""
|
|
259
|
+
self.state.steps += 1
|
|
260
|
+
if not action.tool_calls:
|
|
261
|
+
return (
|
|
262
|
+
[
|
|
263
|
+
Message(
|
|
264
|
+
content=(
|
|
265
|
+
f"Must call one of the provided tools"
|
|
266
|
+
f" { {t.info.name for t in self.tools} }."
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
],
|
|
270
|
+
self.tool_failure_reward,
|
|
271
|
+
self.state.done,
|
|
272
|
+
False,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# We accumulate reward across all tool calls in a step
|
|
276
|
+
self.state.reward = 0.0
|
|
277
|
+
valid_action, invalid_action = self.filter_invalid_tool_calls(action)
|
|
278
|
+
# NOTE: valid_action or invalid_action may have an empty list of tool calls
|
|
279
|
+
response_messages = cast(
|
|
280
|
+
list[Message],
|
|
281
|
+
# Ordered since things like search -> lookup need to be run in order.
|
|
282
|
+
# NOTE: Handling tool exceptions here keeps the trajectory going, but I don't
|
|
283
|
+
# think the returned message is useful to the agent/learning. Disabling for now.
|
|
284
|
+
await self.exec_tool_calls(
|
|
285
|
+
valid_action, ordered=True, handle_tool_exc=False
|
|
286
|
+
)
|
|
287
|
+
+ [
|
|
288
|
+
ToolResponseMessage.from_call(tool_call, content="Invalid tool call.")
|
|
289
|
+
for tool_call in invalid_action.tool_calls
|
|
290
|
+
],
|
|
291
|
+
)
|
|
292
|
+
return response_messages, self.state.reward, self.state.done, False
|
|
293
|
+
|
|
294
|
+
def export_frame(self) -> Frame:
|
|
295
|
+
"""Export the current state of the environment as a Frame object.
|
|
296
|
+
|
|
297
|
+
This method creates and returns a Frame object that captures the current state and additional
|
|
298
|
+
information of the HotPotQA environment. This can be useful for logging, debugging, or visualization
|
|
299
|
+
purposes.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
Frame: A Frame object containing the current state and additional information of the environment.
|
|
303
|
+
|
|
304
|
+
The returned Frame object includes the following information:
|
|
305
|
+
- state (dict): An empty dictionary representing the current state of the environment.
|
|
306
|
+
- info (dict): A dictionary containing:
|
|
307
|
+
- "steps" (int): The number of steps taken in the current episode.
|
|
308
|
+
- "done" (bool): A flag indicating whether the episode is finished.
|
|
309
|
+
- "reward" (float): The accumulated reward in the current episode.
|
|
310
|
+
- "answer" (Optional[str]): The answer provided by the agent, if any.
|
|
311
|
+
|
|
312
|
+
Example:
|
|
313
|
+
>>> env = HotPotQAEnv()
|
|
314
|
+
>>> frame = env.export_frame()
|
|
315
|
+
>>> print(frame.info)
|
|
316
|
+
{'steps': 0, 'done': False, 'reward': 0.0, 'answer': None}
|
|
317
|
+
"""
|
|
318
|
+
return Frame(
|
|
319
|
+
info={
|
|
320
|
+
"steps": self.state.steps,
|
|
321
|
+
"done": self.state.done,
|
|
322
|
+
"reward": self.state.reward,
|
|
323
|
+
"answer": self.state.answer,
|
|
324
|
+
}
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def finish(self, answer: str) -> str:
|
|
328
|
+
"""Finish the episode.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
answer: The answer to the question.
|
|
332
|
+
"""
|
|
333
|
+
self.state.done = True
|
|
334
|
+
if not answer:
|
|
335
|
+
return "Finish failed. No answer provided."
|
|
336
|
+
|
|
337
|
+
self.state.answer = answer
|
|
338
|
+
self.state.reward += self.calculate_reward(answer)
|
|
339
|
+
return "Finished."
|
|
340
|
+
|
|
341
|
+
async def search(self, entity: str) -> str:
|
|
342
|
+
"""Searches Wikipedia for the given entity and processes the results.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
entity: The entity to search for on Wikipedia.
|
|
346
|
+
|
|
347
|
+
Functionality:
|
|
348
|
+
- Constructs and sends a search query to Wikipedia.
|
|
349
|
+
- Parses the search results.
|
|
350
|
+
- If similar results are found, returns a list of similar titles.
|
|
351
|
+
- If the entity page is found, returns a summary of the page content.
|
|
352
|
+
- Handles disambiguation pages by recursively searching with modified query.
|
|
353
|
+
"""
|
|
354
|
+
if not entity:
|
|
355
|
+
self.state.done = True
|
|
356
|
+
return "Search failed. No entity provided."
|
|
357
|
+
|
|
358
|
+
# In case the searched entity is e.g. a year
|
|
359
|
+
search_entity = (
|
|
360
|
+
str(entity) if isinstance(entity, int) else entity.replace(" ", "+") # type: ignore[redundant-expr,unreachable] # noqa: FURB123
|
|
361
|
+
)
|
|
362
|
+
try:
|
|
363
|
+
# Access from cache if we previously searched for search_entity
|
|
364
|
+
response_text = self.wiki_cache[search_entity]
|
|
365
|
+
except KeyError:
|
|
366
|
+
# follow_redirects=True because wikipedia frequently redirects to the correct page
|
|
367
|
+
async with httpx.AsyncClient(
|
|
368
|
+
follow_redirects=True, proxy=self.proxy
|
|
369
|
+
) as client:
|
|
370
|
+
response = await fetch_with_retry(
|
|
371
|
+
client,
|
|
372
|
+
f"https://en.wikipedia.org/w/index.php?search={search_entity}",
|
|
373
|
+
timeout=15,
|
|
374
|
+
)
|
|
375
|
+
response.raise_for_status() # Raise an HTTPError for bad responses
|
|
376
|
+
# Cache for subsequent tool calls
|
|
377
|
+
self.wiki_cache[search_entity] = response_text = response.text
|
|
378
|
+
soup = BeautifulSoup(response_text, features="html.parser")
|
|
379
|
+
result_divs = soup.find_all("div", {"class": "mw-search-result-heading"})
|
|
380
|
+
if result_divs: # mismatch
|
|
381
|
+
result_titles = [clean_str(div.get_text().strip()) for div in result_divs]
|
|
382
|
+
self.state.page = None
|
|
383
|
+
return f"Could not find {entity}. Similar: {result_titles[:5]}."
|
|
384
|
+
|
|
385
|
+
page = [p.get_text().strip() for p in soup.find_all("p") + soup.find_all("ul")]
|
|
386
|
+
if any("may refer to:" in p for p in page): # Recurse
|
|
387
|
+
return await self.search(entity="[" + entity + "]")
|
|
388
|
+
# Clean up any unicode
|
|
389
|
+
self.state.page = ""
|
|
390
|
+
for p in page:
|
|
391
|
+
if len(p.split(" ")) > 2: # noqa: PLR2004
|
|
392
|
+
self.state.page += clean_str(p)
|
|
393
|
+
if not p.endswith("\n"):
|
|
394
|
+
self.state.page += "\n"
|
|
395
|
+
# Extract and concatenate the first five sentences from the provided page content.
|
|
396
|
+
obs_list = [
|
|
397
|
+
s.strip() + "."
|
|
398
|
+
for p in self.state.page.split("\n")
|
|
399
|
+
if p.strip()
|
|
400
|
+
for s in p.split(". ")
|
|
401
|
+
if s.strip()
|
|
402
|
+
]
|
|
403
|
+
return " ".join(obs_list[:5])
|
|
404
|
+
|
|
405
|
+
def construct_lookup_list(self, keyword: str) -> str:
|
|
406
|
+
"""Construct a list of sentences from the given page content that contain the specified keyword.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
keyword: The keyword to search for within the sentences.
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
A list of sentences containing the keyword. If `page` is None or empty, an empty list is returned.
|
|
413
|
+
|
|
414
|
+
Example:
|
|
415
|
+
page_content = ("Milhouse Mussolini Van Houten is a recurring character in the Fox animated television series
|
|
416
|
+
The Simpsons voiced by Pamela Hayden and created by Matt Groening. Milhouse is Bart Simpson's best friend in
|
|
417
|
+
Mrs. Krabappel's fourth grade class at Springfield Elementary School. He is an insecure, gullible, and
|
|
418
|
+
less popular child than Bart who is often led into trouble by Bart, who takes advantage of his friend's naivety.
|
|
419
|
+
Milhouse is a regular target for school bully Nelson Muntz and his friends Jimbo Jones, Dolph Starbeam and
|
|
420
|
+
Kearney Zzyzwicz. Milhouse has a crush on Bart's sister, Lisa, a common plot element.")
|
|
421
|
+
|
|
422
|
+
keyword = "Milhouse"
|
|
423
|
+
result = construct_lookup_list_from_page(keyword, page_content)
|
|
424
|
+
# Output: ['Milhouse Mussolini Van Houten is a recurring character in the Fox animated television series The
|
|
425
|
+
Simpsons.',
|
|
426
|
+
'Milhouse is Bart Simpson's best friend in Mrs. Krabappel's fourth grade class at Springfield Elementary
|
|
427
|
+
School.',
|
|
428
|
+
'Milhouse is a regular target for school bully Nelson Muntz and his friends Jimbo Jones, Dolph Starbeam and
|
|
429
|
+
Kearney Zzyzwicz.',
|
|
430
|
+
'Milhouse has a crush on Bart's sister, Lisa, a common plot element.']
|
|
431
|
+
|
|
432
|
+
"""
|
|
433
|
+
if not keyword:
|
|
434
|
+
self.state.done = True
|
|
435
|
+
return "Lookup failed. No keyword provided"
|
|
436
|
+
|
|
437
|
+
if not self.state.page:
|
|
438
|
+
return "Lookup failed. You have not specified a Wikipedia page yet."
|
|
439
|
+
|
|
440
|
+
if self.state.last_lookup != keyword:
|
|
441
|
+
self.state.last_lookup = keyword
|
|
442
|
+
self.state.lookup_results = [
|
|
443
|
+
s.strip() + "."
|
|
444
|
+
for s in self.state.page.split(". ")
|
|
445
|
+
if s.strip() and keyword.lower() in s.lower()
|
|
446
|
+
]
|
|
447
|
+
self.state.lookup_index = 0
|
|
448
|
+
|
|
449
|
+
if self.state.lookup_index >= len(self.state.lookup_results):
|
|
450
|
+
return "No more results."
|
|
451
|
+
|
|
452
|
+
obs = f"(Result {self.state.lookup_index + 1} / {len(self.state.lookup_results)}) {self.state.lookup_results[self.state.lookup_index]}"
|
|
453
|
+
self.state.lookup_index += 1
|
|
454
|
+
return obs
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
class HotPotQADifficultyLevel(StrEnum):
|
|
458
|
+
EASY = "easy"
|
|
459
|
+
MEDIUM = "medium"
|
|
460
|
+
HARD = "hard"
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class HotPotQAEnvConfig(BaseModel):
|
|
464
|
+
"""Configuration model for the HotPotQA environment.
|
|
465
|
+
|
|
466
|
+
This defines the configuration parameters for setting up the HotPotQA environment,
|
|
467
|
+
including the path to the data repository and the specific path to the prompt file.
|
|
468
|
+
"""
|
|
469
|
+
|
|
470
|
+
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
|
471
|
+
|
|
472
|
+
shuffle_data: bool = Field(
|
|
473
|
+
default=False, description="Set True to shuffle the dataset after loading."
|
|
474
|
+
)
|
|
475
|
+
data_fraction: float = Field(
|
|
476
|
+
default=1.0,
|
|
477
|
+
description=(
|
|
478
|
+
"Fraction of data to use, default is full dataset. If set, data is"
|
|
479
|
+
" subsampled after shuffling (if enabled)."
|
|
480
|
+
),
|
|
481
|
+
gt=0.0,
|
|
482
|
+
le=1.0,
|
|
483
|
+
)
|
|
484
|
+
correct_reward: float = 1.0
|
|
485
|
+
incorrect_reward: float = 0.0
|
|
486
|
+
tool_failure_reward: float = 0.0
|
|
487
|
+
difficulty_level: set[HotPotQADifficultyLevel] | None = Field(
|
|
488
|
+
None,
|
|
489
|
+
description="The HotPotQA difficulty level to filter on. "
|
|
490
|
+
"Can be any subset of `{'easy', 'medium', 'hard'}`. "
|
|
491
|
+
"If None, we will not filter any task instances. "
|
|
492
|
+
"Note that not all splits have a 'level' field.",
|
|
493
|
+
)
|
|
494
|
+
proxy: str | None = None
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
class HotPotQADataset(TaskDataset[HotPotQAEnv]):
|
|
498
|
+
# SEE: https://huggingface.co/datasets/hotpotqa/hotpot_qa
|
|
499
|
+
HOTPOTQA_HUGGING_FACE_DATASET = "hotpotqa/hotpot_qa"
|
|
500
|
+
|
|
501
|
+
def get_data_from_hugging_face(
|
|
502
|
+
self, split: str, hf_dataset: str = HOTPOTQA_HUGGING_FACE_DATASET
|
|
503
|
+
) -> list[tuple[str, str]]:
|
|
504
|
+
"""Convert a local file and split to a list of (question, answer) tuples."""
|
|
505
|
+
if split in {"dev", "eval", "val"}: # Map common aliases
|
|
506
|
+
split = "validation"
|
|
507
|
+
all_datasets = load_dataset(hf_dataset, name="fullwiki", trust_remote_code=True)
|
|
508
|
+
try:
|
|
509
|
+
data = all_datasets[split].select_columns(
|
|
510
|
+
column_names=["question", "answer", "level"]
|
|
511
|
+
)
|
|
512
|
+
except KeyError as exc:
|
|
513
|
+
raise ValueError(
|
|
514
|
+
f"Split {split!r} was invalid for Hugging Face dataset {hf_dataset},"
|
|
515
|
+
f" please specify a split from {set(all_datasets.keys())}."
|
|
516
|
+
) from exc
|
|
517
|
+
|
|
518
|
+
return [(d["question"], d["answer"]) for d in data if self._filter_task(d)]
|
|
519
|
+
|
|
520
|
+
def __init__(
|
|
521
|
+
self, split: str, config: HotPotQAEnvConfig | dict | None = None, **kwargs
|
|
522
|
+
):
|
|
523
|
+
super().__init__()
|
|
524
|
+
if isinstance(config, dict): # Serialized config
|
|
525
|
+
config = HotPotQAEnvConfig(**(config | kwargs))
|
|
526
|
+
elif config is None:
|
|
527
|
+
config = HotPotQAEnvConfig(**kwargs)
|
|
528
|
+
self.config = config
|
|
529
|
+
raw_data = self.get_data_from_hugging_face(split)
|
|
530
|
+
if self.config.shuffle_data:
|
|
531
|
+
random.shuffle(raw_data)
|
|
532
|
+
if self.config.data_fraction < 1.0:
|
|
533
|
+
raw_data = raw_data[: int(self.config.data_fraction * len(raw_data))]
|
|
534
|
+
self.data = raw_data
|
|
535
|
+
|
|
536
|
+
def _filter_task(self, task: dict[str, Any]) -> bool:
|
|
537
|
+
"""Decide whether to keep a task instance based on configuration options."""
|
|
538
|
+
if self.config.difficulty_level is None:
|
|
539
|
+
return True
|
|
540
|
+
|
|
541
|
+
try:
|
|
542
|
+
return task["level"] in self.config.difficulty_level
|
|
543
|
+
except KeyError as e:
|
|
544
|
+
raise RuntimeError(
|
|
545
|
+
"Attempting to filter difficulty level, "
|
|
546
|
+
"but this split does not have a 'level' field."
|
|
547
|
+
) from e
|
|
548
|
+
|
|
549
|
+
def get_new_env_by_idx(self, idx: int) -> HotPotQAEnv:
|
|
550
|
+
return HotPotQAEnv(
|
|
551
|
+
*self.data[idx],
|
|
552
|
+
correct_reward=self.config.correct_reward,
|
|
553
|
+
incorrect_reward=self.config.incorrect_reward,
|
|
554
|
+
tool_failure_reward=self.config.tool_failure_reward,
|
|
555
|
+
proxy=self.config.proxy,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
def __len__(self) -> int:
|
|
559
|
+
return len(self.data)
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# file generated by setuptools_scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
TYPE_CHECKING = False
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from typing import Tuple, Union
|
|
6
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
7
|
+
else:
|
|
8
|
+
VERSION_TUPLE = object
|
|
9
|
+
|
|
10
|
+
version: str
|
|
11
|
+
__version__: str
|
|
12
|
+
__version_tuple__: VERSION_TUPLE
|
|
13
|
+
version_tuple: VERSION_TUPLE
|
|
14
|
+
|
|
15
|
+
__version__ = version = '0.1.0'
|
|
16
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: aviary.hotpotqa
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HotPotQA environment implemented with aviary
|
|
5
|
+
Author-email: FutureHouse technical staff <hello@futurehouse.org>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: beautifulsoup4
|
|
9
|
+
Requires-Dist: datasets
|
|
10
|
+
Requires-Dist: fhaviary
|
|
11
|
+
Requires-Dist: httpx
|
|
12
|
+
Requires-Dist: pydantic~=2.0
|
|
13
|
+
Requires-Dist: tenacity
|
|
14
|
+
|
|
15
|
+
# aviary.hotpotqa
|
|
16
|
+
|
|
17
|
+
HotPotQA environment implemented with aviary.
|
|
18
|
+
|
|
19
|
+
## References
|
|
20
|
+
|
|
21
|
+
[1] Yang et al. [HotpotQA: A Dataset for Diverse,
|
|
22
|
+
Explainable Multi-Hop Question Answering](https://aclanthology.org/D18-1259/). EMNLP, 2018.
|
|
23
|
+
|
|
24
|
+
[2] Yao et al.,
|
|
25
|
+
[ReAct: Synergizing Reasoning and Acting in Language Models](https://openreview.net/forum?id=WE_vluYUL-X).
|
|
26
|
+
In The Eleventh International Conference on Learning Representations. 2023
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/aviary.hotpotqa.egg-info/PKG-INFO
|
|
4
|
+
src/aviary.hotpotqa.egg-info/SOURCES.txt
|
|
5
|
+
src/aviary.hotpotqa.egg-info/dependency_links.txt
|
|
6
|
+
src/aviary.hotpotqa.egg-info/requires.txt
|
|
7
|
+
src/aviary.hotpotqa.egg-info/top_level.txt
|
|
8
|
+
src/aviary/hotpotqa/__init__.py
|
|
9
|
+
src/aviary/hotpotqa/env.py
|
|
10
|
+
src/aviary/hotpotqa/py.typed
|
|
11
|
+
src/aviary/hotpotqa/version.py
|
|
12
|
+
tests/test_hotpotqa_env.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aviary
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from aviary.env import Environment, TaskDataset
|
|
2
|
+
from aviary.hotpotqa import HotPotQAEnv
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_env_construction() -> None:
|
|
6
|
+
hotpotqa_env: HotPotQAEnv = Environment.from_name(
|
|
7
|
+
"hotpotqa",
|
|
8
|
+
question="What is the formula for the volume of Abraham Lincoln's favorite hat?",
|
|
9
|
+
correct_answer="pi*r^2*h",
|
|
10
|
+
)
|
|
11
|
+
assert isinstance(hotpotqa_env, HotPotQAEnv)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_dataset_from_name() -> None:
|
|
15
|
+
dataset = TaskDataset.from_name("hotpotqa", split="dev")
|
|
16
|
+
assert isinstance(dataset.get_new_env_by_idx(0), HotPotQAEnv)
|
|
17
|
+
|
|
18
|
+
# double-check we can load by difficulty level
|
|
19
|
+
dataset = TaskDataset.from_name(
|
|
20
|
+
"hotpotqa", split="train", difficulty_level={"easy", "hard"}
|
|
21
|
+
)
|
|
22
|
+
assert len(dataset) == 33633
|