Aman045's picture
feat: add data generation pipeline
6b7aaad
Raw
History Blame Contribute Delete
16.5 kB
"""Base class for extension agents that create multi-hop retrieval tasks."""
import os
import re
import json
from abc import ABC, abstractmethod
from typing import Dict, Any, List
from concurrent.futures import ThreadPoolExecutor, as_completed
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
from .utils import parse_tag
def get_latest_task(tasks: List[Dict]) -> Dict | None:
"""Get the latest task by level from a list of tasks."""
if not tasks:
return None
sorted_tasks = sorted(tasks, key=lambda t: t.get("level", 0))
return sorted_tasks[-1]
def get_latest_verified_task(tasks: List[Dict]) -> Dict | None:
"""Get the latest task by level that has passed_verification=True."""
if not tasks:
return None
verified_tasks = [t for t in tasks if t.get("passed_verification") is True]
if not verified_tasks:
return None
sorted_tasks = sorted(verified_tasks, key=lambda t: t.get("level", 0))
return sorted_tasks[-1]
class BaseExtenderAgent(ABC):
"""Base class for agents that extend existing tasks with additional hops."""
item_id_tag: str = "id"
system_prompt: str = "You are a helpful assistant."
def __init__(self, client, model: str, max_iterations: int = 20):
self.client = client
self.model = model
self.max_iterations = max_iterations
@abstractmethod
def get_tools(self) -> List[Dict[str, Any]]:
"""Return the list of tools available to this agent."""
pass
@abstractmethod
def execute_tool(self, tool_name: str, tool_args: Dict[str, Any], iteration: int, context: Dict[str, Any]) -> str:
"""Execute a tool and return the output string."""
pass
@abstractmethod
def format_initial_prompt(self, prev_task: Dict[str, Any], **kwargs) -> str:
"""Format the initial prompt for extending from a previous task."""
pass
@abstractmethod
def get_force_output_message(self) -> str:
"""Return the message to force output when max iterations reached."""
pass
@abstractmethod
def get_item_content(self, item_id: str, context: Dict[str, Any]) -> str:
"""Fetch the content for a supporting item by its ID."""
pass
def parse_bridging_item(self, content: str) -> Dict[str, Any] | None:
"""Parse bridging_item from XML format."""
outer_match = re.search(r'<bridging_item>(.*?)</bridging_item>', content, re.DOTALL)
if outer_match:
item_content = outer_match.group(1)
id_match = re.search(rf'<{self.item_id_tag}>(.*?)</{self.item_id_tag}>', item_content, re.DOTALL)
relevant_prev_match = re.search(rf'<relevant_prev_{self.item_id_tag}>(.*?)</relevant_prev_{self.item_id_tag}>', item_content, re.DOTALL)
reasoning_match = re.search(r'<reasoning>(.*?)</reasoning>', item_content, re.DOTALL)
if id_match:
return {
self.item_id_tag: id_match.group(1).strip(),
f'relevant_prev_{self.item_id_tag}': relevant_prev_match.group(1).strip() if relevant_prev_match else '',
'reasoning': reasoning_match.group(1).strip() if reasoning_match else ''
}
return None
def parse_supporting_items(self, content: str) -> List[Dict[str, Any]]:
"""Parse supporting_items from XML format."""
items = []
outer_match = re.search(r'<supporting_items>(.*?)</supporting_items>', content, re.DOTALL)
if outer_match:
items_content = outer_match.group(1)
item_matches = re.findall(r'<item>(.*?)</item>', items_content, re.DOTALL)
for item_match in item_matches:
id_match = re.search(rf'<{self.item_id_tag}>(.*?)</{self.item_id_tag}>', item_match, re.DOTALL)
reasoning_match = re.search(r'<reasoning>(.*?)</reasoning>', item_match, re.DOTALL)
if id_match:
items.append({
self.item_id_tag: id_match.group(1).strip(),
'reasoning': reasoning_match.group(1).strip() if reasoning_match else ''
})
return items
def parse_structured_output(self, content: str) -> Dict[str, Any]:
"""Parse the structured output from the agent."""
return {
"new_clues": parse_tag(content, "new_clues"),
"question": parse_tag(content, "question"),
"truth": parse_tag(content, "truth"),
"bridging_item": self.parse_bridging_item(content),
"supporting_items": self.parse_supporting_items(content)
}
def on_tool_result(self, tool_name: str, tool_args: Dict[str, Any], output: str, context: Dict[str, Any]) -> None:
"""Hook called after each tool execution. Override to track visited items, etc."""
pass
def run_agent_loop(self, input_messages: List, trajectory: List, context: Dict[str, Any]) -> Dict[str, Any] | None:
"""Run the main agent loop with tools."""
request_body = {
"model": self.model,
"system": self.system_prompt,
"max_tokens": 20000,
"tools": self.get_tools(),
"tool_choice": {"type": "auto"},
"thinking": {"type": "enabled", "budget_tokens": 2000}
}
parsed = None
for i in range(self.max_iterations):
request_body["messages"] = input_messages
response = self.client.messages.create(**request_body)
tool_use_items = [item for item in response.content if getattr(item, 'type', None) == 'tool_use']
thinking_items = [item for item in response.content if getattr(item, 'type', None) == 'thinking']
text_items = [item for item in response.content if getattr(item, 'type', None) == 'text']
if thinking_items:
for thinking_item in thinking_items:
trajectory.append({
"type": "thinking",
"output": thinking_item.thinking
})
if not tool_use_items:
for item in text_items:
if item.type == "text":
content = item.text
parsed = self.parse_structured_output(content)
trajectory.append({
"type": "output_text",
"output": content,
**parsed
})
break
serialized_items = []
for item in response.content:
serialized_item = item.model_dump(mode="python")
if 'status' in serialized_item:
del serialized_item['status']
serialized_items.append(serialized_item)
input_messages.append({"role": "assistant", "content": serialized_items})
# Execute tools in parallel when multiple tools are called
if len(tool_use_items) > 1:
tool_results = []
with ThreadPoolExecutor(max_workers=min(len(tool_use_items), 10)) as executor:
# Submit all tool calls
future_to_tool = {
executor.submit(self.execute_tool, tool_call.name, tool_call.input, i, context): tool_call
for tool_call in tool_use_items
}
# Collect results
results_map = {}
for future in as_completed(future_to_tool):
tool_call = future_to_tool[future]
results_map[tool_call.id] = future.result()
# Process in original order for tool_results, on_tool_result hooks, and trajectory
for tool_call in tool_use_items:
output = results_map[tool_call.id]
self.on_tool_result(tool_call.name, tool_call.input, output, context)
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_call.id,
"content": output
})
trajectory.append({
"type": "tool_call",
"tool_name": tool_call.name,
"arguments": tool_call.input,
"output": output
})
else:
# Single tool call - no need for parallelization overhead
tool_results = []
for tool_call in tool_use_items:
tool_args = tool_call.input
tool_name = tool_call.name
output = self.execute_tool(tool_name, tool_args, i, context)
self.on_tool_result(tool_name, tool_args, output, context)
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_call.id,
"content": output
})
trajectory.append({
"type": "tool_call",
"tool_name": tool_name,
"arguments": tool_args,
"output": output
})
input_messages.append({"role": "user", "content": tool_results})
return parsed
def force_output(self, input_messages: List, trajectory: List) -> Dict[str, Any] | None:
"""Force the agent to produce output when max iterations reached."""
input_messages.append({"role": "user", "content": self.get_force_output_message()})
response = self.client.messages.create(
model=self.model,
system=self.system_prompt,
max_tokens=20000,
messages=input_messages,
thinking={"type": "enabled", "budget_tokens": 2000}
)
thinking_items = [item for item in response.content if getattr(item, 'type', None) == 'thinking']
text_items = [item for item in response.content if getattr(item, 'type', None) == 'text']
if thinking_items:
for thinking_item in thinking_items:
trajectory.append({
"type": "thinking",
"output": thinking_item.thinking
})
parsed = None
for item in text_items:
if item.type == "text":
content = item.text
parsed = self.parse_structured_output(content)
trajectory.append({
"type": "forced_output",
"output": content,
**parsed
})
return parsed
def is_valid(self, filepath: str) -> bool:
"""Check if file is valid for extension (has at least one task with passed_verification=True)."""
try:
with open(filepath, "r") as f:
data = json.load(f)
tasks = data.get("tasks", [])
if not tasks:
return False
latest_task = get_latest_task(tasks)
if not latest_task["passed_verification"]:
return False
if not "distractors_and_contents" in latest_task:
return False
if not latest_task["distractors_passed_verification"]:
return False
return True
except (json.JSONDecodeError, KeyError, TypeError):
return False
def is_processed(self, filepath: str) -> bool:
"""Check if file has been fully processed (has a complete task at next level)."""
try:
with open(filepath, "r") as f:
data = json.load(f)
tasks = data.get("tasks", [])
if not tasks:
return False
latest_task = get_latest_task(tasks)
if latest_task is None:
return False
if latest_task.get("clues") is None or latest_task.get("question") is None or latest_task.get("truth") is None:
return False
bridging_item = latest_task.get("bridging_item")
if bridging_item is None or not bridging_item.get(self.item_id_tag):
return False
supporting_items = latest_task.get("supporting_items", [])
if len(supporting_items) != 2:
return False
for item in supporting_items:
if not item.get(self.item_id_tag):
return False
items_and_contents = latest_task.get("items_and_contents", {})
if len(items_and_contents) != 3:
return False
return True
except (json.JSONDecodeError, KeyError, TypeError):
return False
@abstractmethod
def run_single(self, input_filepath: str) -> Dict[str, Any]:
"""Run extension on a single file. Implementation varies by domain."""
pass
def build_result(self, parsed: Dict[str, Any] | None, prev_task: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
"""Build the new task dict from parsed output."""
new_clues = parsed["new_clues"] if parsed else None
question = parsed["question"] if parsed else None
truth = parsed["truth"] if parsed else None
bridging_item = parsed["bridging_item"] if parsed else None
supporting_items = parsed["supporting_items"] if parsed else []
items_and_contents = {}
failed_items = []
all_item_ids = []
if bridging_item and bridging_item.get(self.item_id_tag):
all_item_ids.append(bridging_item[self.item_id_tag])
for item in supporting_items:
if item.get(self.item_id_tag):
all_item_ids.append(item[self.item_id_tag])
for item_id in all_item_ids:
content = self.get_item_content(item_id, context)
if content and not content.startswith("Error"):
items_and_contents[item_id] = content
else:
failed_items.append({"id": item_id, "error": content or "Unknown error"})
prev_level = prev_task.get("level", 0)
return {
"level": prev_level + 1,
"clues": new_clues,
"question": question,
"truth": truth,
"bridging_item": bridging_item,
"supporting_items": supporting_items,
"items_and_contents": items_and_contents,
"failed_items": failed_items
}
def run_batch(self, input_dir: str, max_workers: int = 8) -> Dict[str, Any]:
"""Run batch processing with parallel workers."""
from glob import glob
all_files = glob(os.path.join(input_dir, "*.json"))
valid_files = [f for f in all_files if self.is_valid(f)]
files_to_process = [f for f in valid_files if not self.is_processed(f)]
results = []
errors = []
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
) as progress:
task = progress.add_task(
f"Processing {len(files_to_process)}/{len(valid_files)} valid files",
total=len(files_to_process)
)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_file = {
executor.submit(self.run_single, f): f
for f in files_to_process
}
for future in as_completed(future_to_file):
filepath = future_to_file[future]
try:
result = future.result()
results.append({"file": filepath, "status": "success", "result": result})
except Exception as e:
errors.append({"file": filepath, "error": str(e)})
progress.advance(task)
return {
"total": len(all_files),
"valid": len(valid_files),
"processed": len(files_to_process),
"successful": len(results),
"failed": len(errors),
"errors": errors
}