fleet-memory/benchmarks/longmemeval/longmemeval_benchmark.py
Nicolò Boschi 18c7e0845b fix web init
2025-11-07 12:47:50 +01:00

386 lines
13 KiB
Python

"""
LongMemEval-specific benchmark implementations.
Provides dataset, answer generator, and evaluator for the LongMemEval benchmark.
"""
import sys
from pathlib import Path
from benchmarks.common.benchmark_runner import BenchmarkRunner
from memora import TemporalSemanticMemory
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
import json
from datetime import datetime, timezone
from typing import List, Dict, Any, Tuple, Optional
import asyncio
import pydantic
from openai import AsyncOpenAI
import os
# Import common framework
sys.path.insert(0, str(Path(__file__).parent.parent))
from common.benchmark_runner import BenchmarkDataset, LLMAnswerGenerator, LLMAnswerEvaluator
from memora.llm_wrapper import LLMConfig
class LongMemEvalDataset(BenchmarkDataset):
"""LongMemEval dataset implementation."""
def load(self, path: Path, max_items: Optional[int] = None) -> List[Dict[str, Any]]:
"""Load LongMemEval dataset from JSON file."""
with open(path, 'r') as f:
dataset = json.load(f)
if max_items:
dataset = dataset[:max_items]
return dataset
def get_item_id(self, item: Dict) -> str:
"""Get question ID from LongMemEval item."""
return item.get("question_id", "unknown")
def prepare_sessions_for_ingestion(self, item: Dict) -> List[Dict[str, Any]]:
"""
Prepare LongMemEval conversation sessions for batch ingestion.
Returns:
List of session dicts with 'content', 'context', 'event_date'
"""
sessions = item.get("haystack_sessions", [])
dates = item.get("haystack_dates", [])
session_ids = item.get("haystack_session_ids", [])
# Ensure all lists have same length
if not (len(sessions) == len(dates) == len(session_ids)):
min_len = min(len(sessions), len(dates), len(session_ids))
sessions = sessions[:min_len]
dates = dates[:min_len]
session_ids = session_ids[:min_len]
batch_contents = []
# Process each session
for session_turns, date_str, session_id in zip(sessions, dates, session_ids):
# Parse session date
session_date = self._parse_date(date_str) if date_str else datetime.now(timezone.utc)
# Combine all turns in the session into one content string
session_content_parts = []
for turn_dict in session_turns:
role = turn_dict.get("role", "")
content = turn_dict.get("content", "")
if not content.strip():
continue
# Format as "role: content"
session_content_parts.append(f"{role}: {content}")
# Add session to batch
if session_content_parts:
session_content = "\n".join(session_content_parts)
batch_contents.append({
"content": session_content,
"context": f"Session {session_id}",
"event_date": session_date
})
return batch_contents
def get_qa_pairs(self, item: Dict) -> List[Dict[str, Any]]:
"""
Extract QA pairs from LongMemEval item.
For LongMemEval, each item has one question.
Returns:
List with single QA dict with 'question', 'answer', 'category'
"""
return [{
'question': item.get("question", ""),
'answer': item.get("answer", ""),
'category': item.get("question_type", "unknown")
}]
def _parse_date(self, date_str: str) -> datetime:
"""Parse date string to datetime object."""
try:
# LongMemEval format: "2023/05/20 (Sat) 02:21"
# Try to parse the main part before the day name
date_str_cleaned = date_str.split('(')[0].strip() if '(' in date_str else date_str
# Try multiple formats
for fmt in ["%Y/%m/%d %H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d"]:
try:
dt = datetime.strptime(date_str_cleaned, fmt)
return dt.replace(tzinfo=timezone.utc)
except ValueError:
continue
# Fallback: try ISO format
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
except Exception:
return datetime.now(timezone.utc)
class LongMemEvalAnswerGenerator(LLMAnswerGenerator):
"""LongMemEval-specific answer generator using configurable LLM provider."""
def __init__(self):
"""Initialize with LLM configuration for memory operations."""
self.llm_config = LLMConfig.for_memory()
self.client = self.llm_config.client
self.model = self.llm_config.model
async def generate_answer(
self,
question: str,
memories: List[Dict[str, Any]]
) -> Tuple[str, str, Optional[List[Dict[str, Any]]]]:
"""
Generate answer from retrieved memories using OpenAI.
Returns:
Tuple of (answer, reasoning, retrieved_memories_override)
"""
# Format memories as context
context_parts = []
for i, mem in enumerate(memories, 1):
context_parts.append(f"[Memory {i}] {mem['text']}")
context = "\n".join(context_parts) if context_parts else "No relevant memories found."
prompt = f"""You are a helpful assistant. Based on the following memories from past conversations, answer the question.
Memories:
{context}
Question: {question}
Instructions:
- Answer based ONLY on the provided memories
- If the memories don't contain the answer, say "I don't have enough information to answer this question"
- Be concise and direct
- If asked to abstain (e.g., for unanswerable questions), explicitly say you cannot answer
Answer:"""
try:
answer = await self.llm_config.call(
messages=[{"role": "user", "content": prompt}],
scope="memory",
temperature=0.0,
max_tokens=300
)
return answer.strip(), "", None # LongMemEval doesn't use reasoning or override memories
except Exception as e:
return f"Error generating answer: {str(e)}", "", None
async def run_benchmark(
max_instances: int = None,
max_questions_per_instance: int = None,
thinking_budget: int = 100,
max_tokens: int = 4096,
skip_ingestion: bool = False
):
"""
Run the LongMemEval benchmark.
Args:
max_instances: Maximum number of instances to evaluate (None for all)
max_questions_per_instance: Maximum questions per instance (for testing)
thinking_budget: Thinking budget for spreading activation search
max_tokens: Maximum tokens to retrieve from memories
skip_ingestion: Whether to skip ingestion and use existing data
"""
from rich.console import Console
console = Console()
# Check dataset exists, download if needed
dataset_path = Path(__file__).parent / "datasets" / "longmemeval_s_cleaned.json"
if not dataset_path.exists():
if not download_dataset(dataset_path):
console.print(f"[red]Failed to download dataset. Please download manually:[/red]")
console.print("[yellow]curl -L 'https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_s_cleaned.json' -o benchmarks/longmemeval/datasets/longmemeval_s_cleaned.json[/yellow]")
return
# Initialize components
dataset = LongMemEvalDataset()
answer_generator = LongMemEvalAnswerGenerator()
answer_evaluator = LLMAnswerEvaluator()
memory = TemporalSemanticMemory(
db_url=os.getenv("DATABASE_URL"),
memory_llm_provider=os.getenv("MEMORY_LLM_PROVIDER", "groq"),
memory_llm_api_key=os.getenv("MEMORY_LLM_API_KEY"),
memory_llm_model=os.getenv("MEMORY_LLM_MODEL", "openai/gpt-oss-120b"),
memory_llm_base_url=os.getenv("MEMORY_LLM_BASE_URL") or None, # Use None to get provider defaults
)
# Create benchmark runner
runner = BenchmarkRunner(
dataset=dataset,
answer_generator=answer_generator,
answer_evaluator=answer_evaluator,
memory=memory
)
# Run benchmark
# Two-phase approach: ingest all 500 conversations into single agent, then evaluate all questions
# This is more realistic and tests retrieval from a large memory base
results = await runner.run(
dataset_path=dataset_path,
agent_id="longmemeval",
max_items=max_instances,
max_questions_per_item=max_questions_per_instance,
thinking_budget=thinking_budget,
max_tokens=max_tokens,
skip_ingestion=skip_ingestion,
max_concurrent_questions=8,
eval_semaphore_size=8,
separate_ingestion_phase=True # Ingest all data first, then evaluate all questions
)
# Display and save results
runner.display_results(results)
runner.save_results(results, Path(__file__).parent / 'results' / 'benchmark_results.json')
# Generate detailed report by question type
generate_type_report(results)
return results
def download_dataset(dataset_path: Path) -> bool:
"""
Download the LongMemEval dataset if it doesn't exist.
Returns:
True if successful, False otherwise
"""
import subprocess
from rich.console import Console
console = Console()
url = "https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_s_cleaned.json"
console.print(f"[yellow]Dataset not found. Downloading from HuggingFace...[/yellow]")
console.print(f"[dim]URL: {url}[/dim]")
console.print(f"[dim]Destination: {dataset_path}[/dim]")
# Create parent directory if it doesn't exist
dataset_path.parent.mkdir(parents=True, exist_ok=True)
try:
# Use curl to download with progress
result = subprocess.run(
["curl", "-L", "-o", str(dataset_path), url],
capture_output=True,
text=True,
timeout=300 # 5 minute timeout
)
if result.returncode == 0 and dataset_path.exists():
console.print(f"[green]✓ Dataset downloaded successfully[/green]")
return True
else:
console.print(f"[red]✗ Download failed: {result.stderr}[/red]")
return False
except subprocess.TimeoutExpired:
console.print(f"[red]✗ Download timed out after 5 minutes[/red]")
return False
except Exception as e:
console.print(f"[red]✗ Download error: {e}[/red]")
return False
def generate_type_report(results: dict):
"""Generate a detailed report by question type."""
from rich.table import Table
from rich.console import Console
console = Console()
# Aggregate stats by question type
type_stats = {}
for item_result in results['item_results']:
metrics = item_result['metrics']
by_category = metrics.get('category_stats', {})
for qtype, stats in by_category.items():
if qtype not in type_stats:
type_stats[qtype] = {'total': 0, 'correct': 0}
type_stats[qtype]['total'] += stats['total']
type_stats[qtype]['correct'] += stats['correct']
# Display table
table = Table(title="Performance by Question Type")
table.add_column("Question Type", style="cyan")
table.add_column("Total", justify="right", style="yellow")
table.add_column("Correct", justify="right", style="green")
table.add_column("Accuracy", justify="right", style="magenta")
for qtype, stats in sorted(type_stats.items()):
acc = (stats['correct'] / stats['total'] * 100) if stats['total'] > 0 else 0
table.add_row(
qtype,
str(stats['total']),
str(stats['correct']),
f"{acc:.1f}%"
)
console.print("\n")
console.print(table)
if __name__ == "__main__":
import logging
import argparse
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
parser = argparse.ArgumentParser(description="Run LongMemEval benchmark")
parser.add_argument(
"--max-instances",
type=int,
default=None,
help="Limit number of instances to evaluate (default: all 500)"
)
parser.add_argument(
"--max-questions",
type=int,
default=None,
help="Limit number of questions per instance (for quick testing)"
)
parser.add_argument(
"--thinking-budget",
type=int,
default=100,
help="Thinking budget for spreading activation search"
)
parser.add_argument(
"--max-tokens",
type=int,
default=4096,
help="Maximum tokens to retrieve from memories"
)
parser.add_argument(
"--skip-ingestion",
action="store_true",
help="Skip ingestion and use existing data"
)
args = parser.parse_args()
results = asyncio.run(run_benchmark(
max_instances=args.max_instances,
max_questions_per_instance=args.max_questions,
thinking_budget=args.thinking_budget,
max_tokens=args.max_tokens,
skip_ingestion=args.skip_ingestion
))