fleet-memory/benchmarks/locomo/run_benchmark.py
2025-10-30 12:53:12 +01:00

402 lines
13 KiB
Python

"""
LoComo Benchmark Runner for Entity-Aware Memory System
Evaluates the memory system on the LoComo (Long-term Conversational Memory) benchmark.
"""
import sys
from pathlib import Path
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
import json
from datetime import datetime, timezone, timedelta
from memory import TemporalSemanticMemory
from typing import List, Dict
import openai
from dotenv import load_dotenv
import os
import asyncio
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
from rich.table import Table
from rich import box
load_dotenv()
console = Console()
def parse_date(date_string: str) -> datetime:
"""Parse LoComo date format to datetime."""
# Format: "1:56 pm on 8 May, 2023"
try:
dt = datetime.strptime(date_string, "%I:%M %p on %d %B, %Y")
return dt.replace(tzinfo=timezone.utc)
except:
return datetime.now(timezone.utc)
async def ingest_conversation(memory: TemporalSemanticMemory, conversation_data: Dict, agent_id: str):
"""
Ingest a LoComo conversation into the memory system (ASYNC version).
Ingests entire conversation as a single large document for maximum efficiency.
Args:
memory: Memory system instance
conversation_data: Conversation data from LoComo
agent_id: Agent ID to use
"""
conv = conversation_data['conversation']
speaker_a = conv['speaker_a']
speaker_b = conv['speaker_b']
# Get all session keys sorted
session_keys = sorted([k for k in conv.keys() if k.startswith('session_') and not k.endswith('_date_time')])
total_turns = 0
# Build entire conversation as one large text
conversation_parts = []
for session_key in session_keys:
if session_key not in conv or not isinstance(conv[session_key], list):
continue
session_data = conv[session_key]
# Add all turns from this session
for turn in session_data:
speaker = turn['speaker']
text = turn['text']
conversation_parts.append(f"{speaker} said: {text}")
total_turns += 1
# Ingest entire conversation in ONE put_async call
# Use the first session date as the event date
first_session_key = session_keys[0] if session_keys else "session_1"
date_key = f"{first_session_key}_date_time"
conversation_date = parse_date(conv.get(date_key, "1:00 pm on 1 January, 2023"))
full_conversation = " ".join(conversation_parts)
await memory.put_async(
agent_id=agent_id,
content=full_conversation,
context=f"Full conversation between {speaker_a} and {speaker_b}",
event_date=conversation_date
)
return total_turns
def answer_question(memory: TemporalSemanticMemory, agent_id: str, question: str, thinking_budget: int = 100) -> str:
"""
Answer a question using the memory system.
Args:
memory: Memory system instance
agent_id: Agent ID
question: Question to answer
thinking_budget: How many memory units to explore
Returns:
Answer string
"""
# Search memory
results = memory.search(
agent_id=agent_id,
query=question,
thinking_budget=thinking_budget,
top_k=20 # Get more results for better context
)
print("question:", question)
print("Got results:", results)
if not results:
return "I don't have enough information to answer that question."
# Build context from top results
context_parts = []
for i, result in enumerate(results[:10], 1):
context_parts.append(f"{i}. {result['text']}")
context = "\n".join(context_parts)
# Use OpenAI to generate answer from context
try:
response = openai.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "You are a helpful assistant. Answer the question based ONLY on the provided context. If the context doesn't contain the answer, say 'I don't know'."
},
{
"role": "user",
"content": f"Context:\n{context}\n\nQuestion: {question}\n\nAnswer:"
}
],
temperature=0,
max_tokens=150
)
return response.choices[0].message.content.strip()
except Exception as e:
return f"Error generating answer: {str(e)}"
def evaluate_qa_task(
memory: TemporalSemanticMemory,
agent_id: str,
qa_pairs: List[Dict],
sample_id: str,
max_questions: int = None
) -> Dict:
"""
Evaluate the QA task.
Returns:
Dict with evaluation metrics
"""
results = []
questions_to_eval = qa_pairs[:max_questions] if max_questions else qa_pairs
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
console=console
) as progress:
task = progress.add_task(f"[cyan]Evaluating QA for sample {sample_id}...", total=len(questions_to_eval))
for qa in questions_to_eval:
question = qa['question']
correct_answer = qa['answer']
category = qa.get('category', 0)
# Get predicted answer
predicted_answer = answer_question(memory, agent_id, question)
results.append({
'question': question,
'correct_answer': correct_answer,
'predicted_answer': predicted_answer,
'category': category
})
progress.update(task, advance=1)
return results
def calculate_metrics(results: List[Dict]) -> Dict:
"""
Calculate evaluation metrics.
Uses LLM-as-judge to evaluate answer quality.
"""
correct = 0
total = len(results)
category_stats = {}
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
console=console
) as progress:
task = progress.add_task("[yellow]Judging answers with LLM...", total=total)
for result in results:
# Use LLM as judge
try:
response = openai.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "You are an objective judge. Determine if the predicted answer is semantically equivalent to the correct answer. Answer with ONLY 'yes' or 'no'."
},
{
"role": "user",
"content": f"Question: {result['question']}\nCorrect answer: {result['correct_answer']}\nPredicted answer: {result['predicted_answer']}\n\nAre they equivalent?"
}
],
temperature=0,
max_tokens=5
)
judgment = response.choices[0].message.content.strip().lower()
is_correct = 'yes' in judgment
if is_correct:
correct += 1
result['is_correct'] = is_correct
# Track by category
category = result['category']
if category not in category_stats:
category_stats[category] = {'correct': 0, 'total': 0}
category_stats[category]['total'] += 1
if is_correct:
category_stats[category]['correct'] += 1
except Exception as e:
console.print(f"[red]Error judging answer: {e}[/red]")
result['is_correct'] = False
progress.update(task, advance=1)
accuracy = (correct / total * 100) if total > 0 else 0
return {
'accuracy': accuracy,
'correct': correct,
'total': total,
'category_stats': category_stats,
'detailed_results': results
}
def run_benchmark(max_conversations: int = None, max_questions_per_conv: int = None):
"""
Run the LoComo benchmark.
Args:
max_conversations: Maximum number of conversations to evaluate (None for all)
max_questions_per_conv: Maximum questions per conversation (None for all)
"""
console.print("\n[bold cyan]LoComo Benchmark - Entity-Aware Memory System[/bold cyan]")
console.print("=" * 80)
# Load dataset
console.print("\n[1] Loading LoComo dataset...")
with open('locomo10.json', 'r') as f:
dataset = json.load(f)
conversations_to_eval = dataset[:max_conversations] if max_conversations else dataset
console.print(f" [green]✓[/green] Loaded {len(conversations_to_eval)} conversations")
# Initialize memory system
console.print("\n[2] Initializing memory system...")
memory = TemporalSemanticMemory()
console.print(" [green]✓[/green] Memory system initialized")
# Run evaluation for each conversation
all_results = []
for i, conv_data in enumerate(conversations_to_eval, 1):
sample_id = conv_data['sample_id']
agent_id = f"locomo_{sample_id}"
console.print(f"\n[bold blue]Conversation {i}/{len(conversations_to_eval)}[/bold blue] (Sample ID: {sample_id})")
# Clear previous data
import psycopg2
conn = psycopg2.connect(os.getenv('DATABASE_URL'))
cursor = conn.cursor()
cursor.execute("DELETE FROM memory_units WHERE agent_id = %s", (agent_id,))
cursor.execute("DELETE FROM memory_links WHERE agent_id = %s", (agent_id,))
cursor.execute("DELETE FROM entity_cooccurrences WHERE agent_id = %s", (agent_id,))
cursor.execute("DELETE FROM unit_entities WHERE agent_id = %s", (agent_id,))
cursor.execute("DELETE FROM entities WHERE agent_id = %s", (agent_id,))
conn.commit()
cursor.close()
conn.close()
# Ingest conversation (using async for parallel embedding generation)
console.print(" [3] Ingesting conversation (async with parallel embeddings)...")
total_turns = asyncio.run(ingest_conversation(memory, conv_data, agent_id))
console.print(f" [green]✓[/green] Ingested {total_turns} conversation turns")
# Evaluate QA
console.print(f" [4] Evaluating {len(conv_data['qa'])} QA pairs...")
qa_results = evaluate_qa_task(
memory,
agent_id,
conv_data['qa'],
sample_id,
max_questions=max_questions_per_conv
)
# Calculate metrics
console.print(" [5] Calculating metrics...")
metrics = calculate_metrics(qa_results)
console.print(f" [green]✓[/green] Accuracy: {metrics['accuracy']:.2f}% ({metrics['correct']}/{metrics['total']})")
all_results.append({
'sample_id': sample_id,
'metrics': metrics,
'total_turns': total_turns
})
# Overall results
console.print("\n[bold green]✓ Benchmark Complete![/bold green]\n")
# Calculate overall metrics
total_correct = sum(r['metrics']['correct'] for r in all_results)
total_questions = sum(r['metrics']['total'] for r in all_results)
overall_accuracy = (total_correct / total_questions * 100) if total_questions > 0 else 0
# Display results table
table = Table(title="LoComo Benchmark Results", box=box.ROUNDED)
table.add_column("Sample ID", style="cyan")
table.add_column("Turns", justify="right", style="yellow")
table.add_column("Questions", justify="right", style="blue")
table.add_column("Correct", justify="right", style="green")
table.add_column("Accuracy", justify="right", style="magenta")
for result in all_results:
metrics = result['metrics']
table.add_row(
result['sample_id'],
str(result['total_turns']),
str(metrics['total']),
str(metrics['correct']),
f"{metrics['accuracy']:.1f}%"
)
table.add_row(
"[bold]OVERALL[/bold]",
"-",
f"[bold]{total_questions}[/bold]",
f"[bold]{total_correct}[/bold]",
f"[bold]{overall_accuracy:.1f}%[/bold]"
)
console.print(table)
return {
'overall_accuracy': overall_accuracy,
'total_correct': total_correct,
'total_questions': total_questions,
'conversation_results': all_results
}
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description='Run LoComo benchmark')
parser.add_argument('--max-conversations', type=int, default=None, help='Maximum conversations to evaluate')
parser.add_argument('--max-questions', type=int, default=None, help='Maximum questions per conversation')
args = parser.parse_args()
results = run_benchmark(
max_conversations=args.max_conversations,
max_questions_per_conv=args.max_questions
)
# Save results
with open('benchmark_results.json', 'w') as f:
json.dump(results, f, indent=2)
console.print(f"\n[green]✓[/green] Results saved to benchmark_results.json")