""" LongMemEval Benchmark Evaluation This script evaluates the Entity-Aware Memory System on the LongMemEval benchmark, which tests five core long-term memory abilities: 1. Information extraction 2. Multi-session reasoning 3. Temporal reasoning 4. Knowledge updates 5. Abstention Dataset: LongMemEval-S (~115k tokens, ~40 sessions per instance, 500 questions) Source: https://github.com/xiaowu0162/LongMemEval """ import json import os import sys import argparse from datetime import datetime, timezone from typing import Dict, List, Any from pathlib import Path import time import asyncio import subprocess from dotenv import load_dotenv # Load environment variables from .env load_dotenv() # Add parent directory to path sys.path.insert(0, str(Path(__file__).parent.parent.parent)) from memory import TemporalSemanticMemory from openai import OpenAI from rich.console import Console from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn from rich.table import Table console = Console() def parse_args(): 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( "--output", type=str, default="benchmark_results.json", help="Output file for results" ) parser.add_argument( "--thinking-budget", type=int, default=100, help="Thinking budget for spreading activation search" ) parser.add_argument( "--top-k", type=int, default=20, help="Number of memory units to retrieve per query" ) return parser.parse_args() def download_dataset(dataset_path: Path) -> bool: """ Download the LongMemEval dataset if it doesn't exist. Returns: True if successful, False otherwise """ 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]") 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 load_dataset(dataset_path: str) -> List[Dict[str, Any]]: """Load LongMemEval dataset from JSON file.""" with open(dataset_path, 'r') as f: data = json.load(f) return data def parse_date(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 as e: console.print(f"[yellow]Warning: Failed to parse date '{date_str}': {e}[/yellow]") return datetime.now(timezone.utc) async def ingest_conversation(memory: TemporalSemanticMemory, agent_id: str, instance: Dict[str, Any]) -> None: """ Ingest conversation history into memory system. Args: memory: Memory system instance agent_id: Unique agent ID for this conversation instance: LongMemEval instance containing haystack_sessions """ # LongMemEval format: list of sessions, each session is a list of turn dicts sessions = instance.get("haystack_sessions", []) dates = instance.get("haystack_dates", []) session_ids = instance.get("haystack_session_ids", []) # Ensure all lists have same length if not (len(sessions) == len(dates) == len(session_ids)): console.print(f"[yellow]Warning: Mismatched lengths - sessions:{len(sessions)}, dates:{len(dates)}, ids:{len(session_ids)}[/yellow]") min_len = min(len(sessions), len(dates), len(session_ids)) sessions = sessions[:min_len] dates = dates[:min_len] session_ids = session_ids[:min_len] # Process each session - combine all turns into one put_async call for session_turns, date_str, session_id in zip(sessions, dates, session_ids): # Parse session date session_date = 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" for clarity session_content_parts.append(f"{role}: {content}") # Ingest entire session as one chunk if session_content_parts: session_content = "\n".join(session_content_parts) context = f"Session {session_id}" try: await memory.put_async( agent_id=agent_id, content=session_content, context=context, event_date=session_date ) except Exception as e: console.print(f"[yellow]Warning: Failed to ingest session {session_id}: {e}[/yellow]") async def retrieve_memories( memory: TemporalSemanticMemory, agent_id: str, query: str, thinking_budget: int, top_k: int ) -> List[Dict[str, Any]]: """ Retrieve relevant memories for a query. Args: memory: Memory system instance agent_id: Agent ID query: Query text thinking_budget: Thinking budget for search top_k: Number of results to return Returns: List of retrieved memory units """ try: results = await memory.search_async( agent_id=agent_id, query=query, thinking_budget=thinking_budget, top_k=top_k ) return results except Exception as e: console.print(f"[yellow]Warning: Search failed: {e}[/yellow]") return [] def generate_answer( client: OpenAI, question: str, memories: List[Dict[str, Any]], model: str = "gpt-4o-mini" ) -> str: """ Generate answer to question using retrieved memories. Args: client: OpenAI client question: Question text memories: Retrieved memory units model: OpenAI model to use Returns: Generated answer """ # 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: response = client.chat.completions.create( model=model, messages=[{"role": "user", "content": prompt}], temperature=0.0, max_tokens=300 ) return response.choices[0].message.content.strip() except Exception as e: console.print(f"[yellow]Warning: Answer generation failed: {e}[/yellow]") return "Error generating answer" def evaluate_answer( client: OpenAI, question: str, predicted_answer: str, gold_answer: str, model: str = "gpt-4o" ) -> Dict[str, Any]: """ Evaluate predicted answer against gold answer using LLM-as-judge. Args: client: OpenAI client question: Question text predicted_answer: Predicted answer gold_answer: Gold answer model: OpenAI model to use for evaluation Returns: Evaluation result with score and explanation """ prompt = f"""You are an expert evaluator. Evaluate if the predicted answer is semantically equivalent to the gold answer. Question: {question} Gold Answer: {gold_answer} Predicted Answer: {predicted_answer} Instructions: - Score 1 if the predicted answer is semantically equivalent (same meaning, different wording is OK) - Score 1 if the predicted answer correctly abstains when the gold answer indicates the question is unanswerable - Score 0 if the predicted answer is incorrect or contradicts the gold answer - Score 0 if the predicted answer provides an answer when it should abstain - Provide a brief explanation Output format: Score: [0 or 1] Explanation: [brief explanation]""" try: response = client.chat.completions.create( model=model, messages=[{"role": "user", "content": prompt}], temperature=0.0, max_tokens=200 ) content = response.choices[0].message.content.strip() # Parse score and explanation lines = content.split('\n') score = 0 explanation = "" for line in lines: if line.startswith("Score:"): score_str = line.replace("Score:", "").strip() score = int(score_str) if score_str.isdigit() else 0 elif line.startswith("Explanation:"): explanation = line.replace("Explanation:", "").strip() return { "score": score, "explanation": explanation, "raw_output": content } except Exception as e: console.print(f"[yellow]Warning: Evaluation failed: {e}[/yellow]") return { "score": 0, "explanation": f"Evaluation error: {str(e)}", "raw_output": "" } def run_benchmark(args): """Run the LongMemEval benchmark evaluation.""" console.print("\n[bold cyan]LongMemEval Benchmark Evaluation[/bold cyan]\n") # Load dataset - download if needed dataset_path = Path(__file__).parent / "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/longmemeval_s_cleaned.json[/yellow]") return console.print(f"[green]Loading dataset from {dataset_path}[/green]") dataset = load_dataset(dataset_path) if args.max_instances: dataset = dataset[:args.max_instances] console.print(f"[yellow]Limited to {args.max_instances} instances[/yellow]") console.print(f"Dataset size: {len(dataset)} instances\n") # Initialize memory system console.print("[cyan]Initializing memory system...[/cyan]") memory = TemporalSemanticMemory() # Initialize OpenAI client openai_api_key = os.getenv("OPENAI_API_KEY") if not openai_api_key: console.print("[red]Error: OPENAI_API_KEY not set[/red]") return client = OpenAI(api_key=openai_api_key) # Results storage results = [] # Process each instance with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), TimeElapsedColumn(), console=console ) as progress: instance_task = progress.add_task("[cyan]Processing instances...", total=len(dataset)) for idx, instance in enumerate(dataset): question_id = instance.get("question_id", f"q_{idx}") question = instance.get("question", "") gold_answer = instance.get("answer", "") question_type = instance.get("question_type", "unknown") progress.update(instance_task, description=f"[cyan]Instance {idx+1}/{len(dataset)}: {question_id}") # Use single agent for all LongMemEval data (cleared per question for isolation) agent_id = "longmemeval" # Clear agent data for this question (each question needs fresh isolated context) memory.delete_agent(agent_id) # Ingest conversation history try: asyncio.run(ingest_conversation(memory, agent_id, instance)) except Exception as e: console.print(f"[red]Error ingesting instance {question_id}: {e}[/red]") continue # Retrieve memories memories = asyncio.run(retrieve_memories( memory, agent_id, question, args.thinking_budget, args.top_k )) # Generate answer predicted_answer = generate_answer(client, question, memories) # Evaluate answer evaluation = evaluate_answer(client, question, predicted_answer, gold_answer) # Store result result = { "question_id": question_id, "question_type": question_type, "question": question, "gold_answer": gold_answer, "predicted_answer": predicted_answer, "score": evaluation["score"], "explanation": evaluation["explanation"], "num_memories_retrieved": len(memories), "memory_texts": [m["text"] for m in memories[:5]] # Store top 5 for debugging } results.append(result) progress.update(instance_task, advance=1) # Save intermediate results if (idx + 1) % 10 == 0: save_results(results, args.output) # Save final results save_results(results, args.output) # Display summary display_summary(results) def save_results(results: List[Dict[str, Any]], output_path: str): """Save results to JSON file.""" output_file = Path(__file__).parent / output_path with open(output_file, 'w') as f: json.dump(results, f, indent=2) console.print(f"[green]Results saved to {output_file}[/green]") def display_summary(results: List[Dict[str, Any]]): """Display benchmark summary.""" console.print("\n[bold cyan]Benchmark Summary[/bold cyan]\n") # Overall accuracy total = len(results) correct = sum(1 for r in results if r["score"] == 1) accuracy = (correct / total * 100) if total > 0 else 0 table = Table(title="Overall Performance") table.add_column("Metric", style="cyan") table.add_column("Value", style="green") table.add_row("Total Questions", str(total)) table.add_row("Correct", str(correct)) table.add_row("Incorrect", str(total - correct)) table.add_row("Accuracy", f"{accuracy:.2f}%") console.print(table) # Accuracy by question type type_stats = {} for result in results: qtype = result["question_type"] if qtype not in type_stats: type_stats[qtype] = {"total": 0, "correct": 0} type_stats[qtype]["total"] += 1 type_stats[qtype]["correct"] += result["score"] type_table = Table(title="Performance by Question Type") type_table.add_column("Question Type", style="cyan") type_table.add_column("Total", style="yellow") type_table.add_column("Correct", style="green") type_table.add_column("Accuracy", style="green") for qtype, stats in sorted(type_stats.items()): acc = (stats["correct"] / stats["total"] * 100) if stats["total"] > 0 else 0 type_table.add_row( qtype, str(stats["total"]), str(stats["correct"]), f"{acc:.2f}%" ) console.print("\n") console.print(type_table) if __name__ == "__main__": args = parse_args() run_benchmark(args)