mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-04 02:33:54 +00:00
Some checks failed
Pre-commit / run (ubuntu-latest) (push) Has been cancelled
* feat: 新增 ReMeLight 文件版记忆的 LoCoMo 评测脚本 * feat: add LoCoMo eval for ReMeLight file-based memory * fix: resolve pre-commit lint issues * fix: align answer model with eval_reme.py (qwen3-30b-a3b-instruct-2507) * fix: add --disable=E0611
1195 lines
42 KiB
Python
1195 lines
42 KiB
Python
"""
|
||
ReMeLight (file-based) evaluation script for LoCoMo benchmark.
|
||
|
||
This script evaluates the file-based memory system (ReMeLight) on the
|
||
LoCoMo benchmark, using the same evaluation protocol as the vector-based
|
||
eval_reme.py but adapted for ReMeLight's file-based API.
|
||
|
||
Pipeline:
|
||
1. Load LoCoMo data
|
||
2. For each user conversation:
|
||
a. Initialize ReMeLight with per-user working_dir
|
||
b. Process all sessions via summary_memory() -> writes memory/*.md
|
||
c. Answer questions via memory_search() -> LLM generates answer
|
||
d. Judge answers via LLM-as-Judge (GPT-4o-mini)
|
||
3. Aggregate and report metrics
|
||
|
||
Usage:
|
||
python benchmark/locomo/eval_reme_light.py \
|
||
--data_path locomo10.json \
|
||
--top_k 20 --user_num 5 --max_concurrency 2
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import time
|
||
from pathlib import Path
|
||
from datetime import datetime, timedelta
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
from agentscope.message import Msg
|
||
from loguru import logger
|
||
|
||
from reme.reme_light import ReMeLight
|
||
|
||
|
||
# ==================== Configuration ====================
|
||
@dataclass
|
||
class EvalConfig:
|
||
"""Evaluation configuration parameters."""
|
||
|
||
data_path: str = ""
|
||
top_k: int = 20
|
||
user_num: int = 1
|
||
max_concurrency: int = 2
|
||
batch_size: int = 40
|
||
output_dir: str = "bench_results/reme_light"
|
||
reme_model_name: str = "qwen-flash"
|
||
eval_model_name: str = "qwen3-max"
|
||
# Time to wait for FileWatcher to re-index after file writes (seconds)
|
||
index_wait_seconds: int = 5
|
||
# Skip summarization, go straight to QA (reuse existing working_dir)
|
||
resume: bool = False
|
||
|
||
|
||
# ==================== Utilities ====================
|
||
|
||
|
||
class DataLoader:
|
||
"""Handles loading and parsing of LoCoMo data."""
|
||
|
||
@staticmethod
|
||
def load_json(file_path: str) -> dict:
|
||
"""Load and parse a JSON file."""
|
||
with open(file_path, "r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
|
||
@staticmethod
|
||
def format_dialogue_messages(
|
||
dialogue: list[dict],
|
||
speaker_a: str,
|
||
base_timestamp: datetime,
|
||
time_interval: int,
|
||
) -> list[Msg]:
|
||
"""Format LoCoMo dialogue into agentscope Msg objects for ReMeLight."""
|
||
messages: list[Msg] = []
|
||
for idx, turn in enumerate(dialogue):
|
||
role = "user" if turn["speaker"] == speaker_a else "assistant"
|
||
ts = (base_timestamp + timedelta(seconds=idx * time_interval)).strftime(
|
||
"%Y-%m-%d %H:%M:%S",
|
||
)
|
||
msg = Msg(
|
||
name=turn["speaker"],
|
||
content=turn["text"],
|
||
role=role,
|
||
metadata={"time_created": ts},
|
||
)
|
||
messages.append(msg)
|
||
return messages
|
||
|
||
|
||
class FileManager:
|
||
"""Manages file I/O operations for eval results."""
|
||
|
||
def __init__(self, base_dir: str):
|
||
self.base_dir = Path(base_dir)
|
||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
def get_user_dir(self, user_name: str) -> Path:
|
||
"""Get or create the output directory for a user."""
|
||
user_dir = self.base_dir / user_name
|
||
user_dir.mkdir(parents=True, exist_ok=True)
|
||
return user_dir
|
||
|
||
def get_session_file(self, user_name: str, session_id: int) -> Path:
|
||
"""Get the file path for a session result."""
|
||
return self.get_user_dir(user_name) / f"session_{session_id}.json"
|
||
|
||
def save_session(self, user_name: str, session_id: int, data: dict):
|
||
"""Save session evaluation data to a JSON file."""
|
||
file_path = self.get_session_file(user_name, session_id)
|
||
with open(file_path, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||
|
||
def user_has_cache(self, user_name: str) -> bool:
|
||
"""Check if cached evaluation data exists for a user."""
|
||
user_dir = self.get_user_dir(user_name)
|
||
has_sessions = any(f.name.startswith("session_") and f.suffix == ".json" for f in user_dir.iterdir())
|
||
has_questions = (user_dir / "questions.json").exists()
|
||
return has_sessions and has_questions
|
||
|
||
def combine_results(self, output_file: str):
|
||
"""Combine all user session files into a single JSONL results file."""
|
||
with open(output_file, "w", encoding="utf-8") as f_out:
|
||
for user_dir in sorted(self.base_dir.iterdir()):
|
||
if not user_dir.is_dir():
|
||
continue
|
||
|
||
session_files = sorted(
|
||
f for f in user_dir.iterdir() if f.name.startswith("session_") and f.suffix == ".json"
|
||
)
|
||
if not session_files:
|
||
continue
|
||
|
||
with open(session_files[0], "r", encoding="utf-8") as f_in:
|
||
first_session = json.load(f_in)
|
||
|
||
user_data = {
|
||
"uuid": first_session["uuid"],
|
||
"user_name": first_session["user_name"],
|
||
"sessions": [],
|
||
}
|
||
|
||
for sf in session_files:
|
||
with open(sf, "r", encoding="utf-8") as f_in:
|
||
session_data = json.load(f_in)
|
||
session_data.pop("uuid", None)
|
||
session_data.pop("user_name", None)
|
||
user_data["sessions"].append(session_data)
|
||
|
||
question_file = user_dir / "questions.json"
|
||
if question_file.exists():
|
||
with open(question_file, "r", encoding="utf-8") as f_in:
|
||
user_data["evaluation_results"] = json.load(f_in)
|
||
|
||
f_out.write(json.dumps(user_data, ensure_ascii=False) + "\n")
|
||
|
||
|
||
# ==================== Memory Operations ====================
|
||
|
||
|
||
class ReMeLightMemoryProcessor:
|
||
"""Handles ReMeLight memory operations for eval."""
|
||
|
||
def __init__(
|
||
self,
|
||
eval_model_name: str = "qwen3-max",
|
||
index_wait_seconds: int = 5,
|
||
):
|
||
self.eval_model_name = eval_model_name
|
||
self.index_wait_seconds = index_wait_seconds
|
||
|
||
# 每 0.5 秒检查 file_store 有没有索引文件,有了立刻返回
|
||
async def _wait_for_index(self, reme: ReMeLight, timeout: int = 10):
|
||
"""Poll file_store until indexed files appear, instead of blind sleep."""
|
||
from reme.core.enumeration import MemorySource
|
||
|
||
fs = reme.service_context.file_stores["default"]
|
||
for _ in range(timeout * 2): # 每 0.5 秒检查一次
|
||
files = await fs.list_files(MemorySource.MEMORY)
|
||
if files:
|
||
return
|
||
await asyncio.sleep(0.5)
|
||
logger.warning("FileWatcher indexing timed out, proceeding anyway")
|
||
|
||
async def add_memories(
|
||
self,
|
||
reme: ReMeLight,
|
||
messages: list[Msg],
|
||
batch_size: int = 10000,
|
||
) -> tuple[str, float]:
|
||
"""Process session messages and persist to memory files.
|
||
|
||
Returns:
|
||
tuple: (summary_text, duration_ms)
|
||
"""
|
||
start = time.time()
|
||
|
||
summary_text = ""
|
||
for i in range(0, len(messages), batch_size):
|
||
batch = messages[i : i + batch_size]
|
||
try:
|
||
result = await reme.summary_memory(
|
||
messages=batch,
|
||
language="en",
|
||
)
|
||
summary_text += result
|
||
except Exception as e:
|
||
logger.error(f"summary_memory failed for batch: {e}")
|
||
|
||
# 轮询等 FileWatcher 重建索引,替代 sleep(5)
|
||
await self._wait_for_index(reme)
|
||
|
||
duration_ms = (time.time() - start) * 1000
|
||
return summary_text, duration_ms
|
||
|
||
# LLM 生成 4 个变体问题,逐个搜,按 (path, line) 去重合并
|
||
async def _multi_query_search(
|
||
self,
|
||
reme: ReMeLight,
|
||
question: str,
|
||
top_k: int,
|
||
) -> list[dict]:
|
||
"""Generate multiple query variations and merge search results."""
|
||
llm = reme.service_context.as_llms.get("default")
|
||
if llm is None:
|
||
return []
|
||
|
||
# 让 LLM 生成查询变体
|
||
prompt = _QUERY_VARIATIONS_PROMPT.format(question=question, n=4)
|
||
try:
|
||
resp = await asyncio.wait_for(
|
||
llm(messages=[{"role": "user", "content": prompt}]),
|
||
timeout=30,
|
||
)
|
||
text = "".join(b["text"] if isinstance(b, dict) else getattr(b, "text", "") for b in (resp.content or []))
|
||
# 按行解析变体
|
||
variations = [q.strip("- ").strip() for q in text.split("\n") if q.strip("- ").strip()]
|
||
except Exception:
|
||
variations = []
|
||
|
||
# 原始问题 + 变体,去重
|
||
all_queries = list(dict.fromkeys([question] + variations[:4]))
|
||
logger.info(f" Multi-query: {len(all_queries)} queries")
|
||
|
||
# 逐个搜索,按 merge_key 去重合并
|
||
seen = set()
|
||
merged: dict[str, dict] = {}
|
||
for q in all_queries:
|
||
try:
|
||
sr = await reme.memory_search(query=q, max_results=top_k, min_score=0.1)
|
||
for r in _parse_search_results(sr):
|
||
key = f"{r.get('path', '')}:{r.get('start_line', '')}"
|
||
if key not in seen:
|
||
seen.add(key)
|
||
merged[key] = r
|
||
except Exception:
|
||
continue
|
||
|
||
results = sorted(merged.values(), key=lambda r: r.get("score", 0), reverse=True)
|
||
return results[:top_k]
|
||
|
||
# 多轮检索 最多 3 轮, LLM 判断信息够不够,不够生成新查询再搜
|
||
async def _multi_round_search(
|
||
self,
|
||
reme: ReMeLight,
|
||
question: str,
|
||
top_k: int,
|
||
max_rounds: int = 3,
|
||
) -> list[dict]:
|
||
"""Multi-round retrieval: search, check sufficiency, refine query if needed."""
|
||
all_results = await self._multi_query_search(reme, question, top_k)
|
||
if not all_results:
|
||
return []
|
||
|
||
llm = reme.service_context.as_llms.get("default")
|
||
if llm is None or max_rounds <= 1:
|
||
return all_results
|
||
|
||
# 后续轮次
|
||
seen_keys = {f"{r.get('path', '')}:{r.get('start_line', '')}" for r in all_results}
|
||
for round_idx in range(1, max_rounds):
|
||
# LLM 判断是否足够,不够则给新查询
|
||
context = _format_search_results_for_prompt(all_results[:10])
|
||
check_prompt = _SUFFICIENCY_CHECK_PROMPT.format(
|
||
question=question,
|
||
context=context,
|
||
)
|
||
try:
|
||
resp = await asyncio.wait_for(
|
||
llm(messages=[{"role": "user", "content": check_prompt}]),
|
||
timeout=60,
|
||
)
|
||
text = "".join(
|
||
b["text"] if isinstance(b, dict) else getattr(b, "text", "") for b in (resp.content or [])
|
||
)
|
||
except Exception:
|
||
break
|
||
|
||
# 解析 LLM 决策
|
||
if "SUFFICIENT" in text.upper() and "INSUFFICIENT" not in text.upper():
|
||
break # 够了就停
|
||
|
||
# 提取新查询
|
||
new_query = ""
|
||
for line in text.split("\n"):
|
||
if "NEW_QUERY:" in line.upper():
|
||
new_query = line.split(":", 1)[-1].strip()
|
||
break
|
||
if not new_query:
|
||
break
|
||
|
||
logger.info(f" Round {round_idx + 1}: refined query -> {new_query[:60]}...")
|
||
new_results = await self._multi_query_search(reme, new_query, top_k // 2)
|
||
for r in new_results:
|
||
key = f"{r.get('path', '')}:{r.get('start_line', '')}"
|
||
if key not in seen_keys:
|
||
seen_keys.add(key)
|
||
all_results.append(r)
|
||
|
||
return sorted(all_results, key=lambda r: r.get("score", 0), reverse=True)[:top_k]
|
||
|
||
async def search_memory(
|
||
self,
|
||
reme: ReMeLight,
|
||
query: str,
|
||
top_k: int = 20,
|
||
) -> tuple[dict, list, float]:
|
||
"""Multi-round, multi-query memory search with LLM answer generation.
|
||
|
||
Returns:
|
||
tuple: (answer_dict, raw_search_results, duration_ms)
|
||
"""
|
||
start = time.time()
|
||
|
||
# 多轮 + 多查询检索
|
||
raw_results = await self._multi_round_search(reme, query, top_k)
|
||
|
||
# LLM 基于搜到的记忆生成结构化回答
|
||
answer_dict = await _answer_question_with_memories(
|
||
reme=reme,
|
||
question=query,
|
||
search_results=raw_results,
|
||
_model_name=self.eval_model_name,
|
||
)
|
||
|
||
duration_ms = (time.time() - start) * 1000
|
||
return answer_dict, raw_results, duration_ms
|
||
|
||
|
||
def _parse_search_results(search_result) -> list[dict]:
|
||
"""Parse ReMeLight memory_search ToolResponse into list of result dicts."""
|
||
try:
|
||
if not search_result.content:
|
||
return []
|
||
# ToolResponse.content is a list of dicts, each with 'type' and 'text' keys
|
||
block = search_result.content[0]
|
||
if isinstance(block, dict):
|
||
text = block.get("text", "[]")
|
||
elif hasattr(block, "text"):
|
||
text = block.text
|
||
else:
|
||
return []
|
||
return json.loads(text)
|
||
except (json.JSONDecodeError, AttributeError, IndexError, TypeError):
|
||
logger.warning("Failed to parse memory_search results")
|
||
return []
|
||
|
||
|
||
def _format_search_results_for_prompt(results: list[dict]) -> str:
|
||
"""Format raw search results into a prompt-friendly string."""
|
||
if not results:
|
||
return "No relevant memories found."
|
||
|
||
lines = []
|
||
for i, r in enumerate(results, 1):
|
||
path = r.get("path", "unknown")
|
||
snippet = r.get("snippet", r.get("content", ""))
|
||
score = r.get("score", 0)
|
||
lines.append(f"[{i}] {path} (score={score:.2f}):\n{snippet}")
|
||
return "\n\n".join(lines)
|
||
|
||
|
||
async def _answer_question_with_memories(
|
||
reme: ReMeLight,
|
||
question: str,
|
||
search_results: list[dict],
|
||
_model_name: str = "qwen3-30b-a3b-instruct-2507",
|
||
) -> dict:
|
||
# 把搜索结果格式化成 prompt 上下文
|
||
memories_text = _format_search_results_for_prompt(search_results)
|
||
context = f"Memories from file-based memory system:\n{memories_text}"
|
||
# 填入 prompt 模板
|
||
prompt = _PROMPT_MEMZERO_JSON.format(context=context, question=question)
|
||
|
||
llm = reme.service_context.as_llms.get("default")
|
||
if llm is None:
|
||
logger.error("No default LLM available")
|
||
return {"reasoning": "LLM not available", "answer": ""}
|
||
|
||
try:
|
||
response = await asyncio.wait_for(
|
||
llm(messages=[{"role": "user", "content": prompt}]),
|
||
timeout=120, # 2 minutes per LLM call
|
||
)
|
||
text = ""
|
||
for b in response.content or []:
|
||
t = b["text"] if isinstance(b, dict) else getattr(b, "text", "")
|
||
if t:
|
||
text += t
|
||
# Try parsing JSON from the response
|
||
json_match = re.search(r"\{[^}]+\}", text, re.DOTALL)
|
||
if json_match:
|
||
return json.loads(json_match.group(0))
|
||
return {"reasoning": text, "answer": text}
|
||
except asyncio.TimeoutError:
|
||
logger.error("LLM answer generation timed out")
|
||
return {"reasoning": "LLM timeout", "answer": ""}
|
||
except Exception as e:
|
||
logger.error(f"LLM answer generation failed: {e}")
|
||
return {"reasoning": str(e), "answer": ""}
|
||
|
||
|
||
# 每个问题被裁判两次:
|
||
# 1. LLM 整理后的回答 vs 标准答案
|
||
# 2. 原始搜出来的记忆片段 vs 标准答案(衡量检索本身的质量)
|
||
async def _evaluation_for_question(
|
||
reme: ReMeLight,
|
||
question: str,
|
||
golden_answer: str, # 数据集标准答案
|
||
generated_answer: str, # LLM 生成的回答
|
||
_model_name: str = "qwen3-max",
|
||
) -> dict:
|
||
"""LLM-as-Judge: compare generated answer with golden answer."""
|
||
await asyncio.sleep(2) # Rate limiting
|
||
|
||
user_prompt = _USER_PROMPT_TEMPLATE.format(
|
||
question=question,
|
||
golden_answer=golden_answer,
|
||
generated_answer=generated_answer,
|
||
)
|
||
|
||
llm = reme.service_context.as_llms.get("default")
|
||
if llm is None:
|
||
return {"reasoning": "LLM not available", "evaluation_result": False}
|
||
|
||
try:
|
||
# 调 LLM 当裁判,2 分钟超时
|
||
response = await asyncio.wait_for(
|
||
llm(
|
||
messages=[
|
||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||
{"role": "user", "content": user_prompt},
|
||
],
|
||
),
|
||
timeout=120,
|
||
)
|
||
|
||
content = ""
|
||
for b in response.content or []:
|
||
t = b["text"] if isinstance(b, dict) else getattr(b, "text", "")
|
||
if t:
|
||
content += t
|
||
|
||
match = re.search(r'"label"\s*:\s*"([^"]*?)"', content)
|
||
if match:
|
||
label = match.group(1)
|
||
else:
|
||
# Fallback: look for CORRECT or WRONG anywhere in response
|
||
if "CORRECT" in content.upper():
|
||
label = "CORRECT"
|
||
else:
|
||
label = "WRONG"
|
||
|
||
return {
|
||
"reasoning": content,
|
||
"evaluation_result": label.strip().upper() == "CORRECT",
|
||
}
|
||
except asyncio.TimeoutError:
|
||
logger.error("Evaluation LLM call timed out")
|
||
return {"reasoning": "LLM timeout", "evaluation_result": False}
|
||
except Exception as e:
|
||
logger.error(f"Evaluation LLM call failed: {e}")
|
||
return {"reasoning": str(e), "evaluation_result": False}
|
||
|
||
|
||
# ==================== Evaluation Prompt Templates ====================
|
||
|
||
_SYSTEM_PROMPT = "You are an expert grader that determines if answers to questions match a gold standard answer"
|
||
|
||
_USER_PROMPT_TEMPLATE = (
|
||
"Your task is to label an answer to a question as 'CORRECT' or 'WRONG'."
|
||
" You will be given the following data:\n"
|
||
" (1) a question (posed by one user to another user),\n"
|
||
" (2) a 'gold' (ground truth) answer,\n"
|
||
" (3) a generated answer\n"
|
||
"which you will score as CORRECT/WRONG.\n"
|
||
"\n"
|
||
"The point of the question is to ask about something one user should know "
|
||
"about the other user based on their prior conversations.\n"
|
||
"The gold answer will usually be a concise and short answer that includes "
|
||
"the referenced topic.\n"
|
||
"\n"
|
||
"For time related questions, the gold answer will be a specific date, "
|
||
"month, year, etc. The generated answer might be much longer or use "
|
||
"relative time references, but you should be generous with your grading "
|
||
"- as long as it refers to the same date or time period as the gold "
|
||
"answer, it should be counted as CORRECT.\n"
|
||
"\n"
|
||
"Now it's time for the real question:\n"
|
||
"Question: {question}\n"
|
||
"Gold answer: {golden_answer}\n"
|
||
"Generated answer: {generated_answer}\n"
|
||
"\n"
|
||
"First, provide a short (one sentence) explanation of your reasoning, "
|
||
"then finish with CORRECT or WRONG.\n"
|
||
"Do NOT include both CORRECT and WRONG in your response.\n"
|
||
"\n"
|
||
'Just return the label CORRECT or WRONG in a json format with the key as "label".'
|
||
)
|
||
_PROMPT_MEMZERO_JSON = """# CONTEXT:
|
||
{context}
|
||
|
||
# CONTEXT PRIORITY:
|
||
When the context contains information from multiple sources, follow this strict priority order:
|
||
1. **Historical Dialogue** (highest priority) - Direct conversation content
|
||
2. **Extracted Memories** (medium priority) - Summarized memory points
|
||
3. **User Profile** (lowest priority) - General user information
|
||
|
||
# Question:
|
||
{question}
|
||
|
||
# INSTRUCTIONS:
|
||
1. Carefully analyze all provided memories (facts and entities)
|
||
2. Pay special attention to the timestamps to determine when events occurred
|
||
3. If the question asks about a specific event or fact, look for direct evidence
|
||
4. If the memories contain contradictory information, prioritize the most recent memory
|
||
5. Always convert relative time references to specific dates, months, or years
|
||
6. Be as specific as possible when talking about people, places, and events
|
||
|
||
# OUTPUT FORMAT:
|
||
Please provide your response in the following JSON format:
|
||
|
||
```json
|
||
{{
|
||
"reasoning": "reasoning content",
|
||
"answer": "Provide a detailed answer"
|
||
}}
|
||
```"""
|
||
|
||
_QUERY_VARIATIONS_PROMPT = (
|
||
"Generate {n} search query variations for the question below. Each variation "
|
||
"should use different wording, focus on different entities, or approach from "
|
||
'a different angle. Output one query per line, starting with "- ".\n'
|
||
"\n"
|
||
"Question: {question}\n"
|
||
"\n"
|
||
"Queries:"
|
||
)
|
||
|
||
_SUFFICIENCY_CHECK_PROMPT = (
|
||
"You are evaluating whether retrieved memories contain enough information "
|
||
"to answer a question.\n"
|
||
"\n"
|
||
"Question: {question}\n"
|
||
"\n"
|
||
"Retrieved memories:\n"
|
||
"{context}\n"
|
||
"\n"
|
||
"If the memories contain sufficient information to answer the question, reply:\n"
|
||
"SUFFICIENT\n"
|
||
"\n"
|
||
"If more information is needed, reply:\n"
|
||
"INSUFFICIENT\n"
|
||
"NEW_QUERY: <a refined search query to find the missing information>\n"
|
||
"\n"
|
||
"Reply:"
|
||
)
|
||
|
||
|
||
# ==================== Evaluation Classes ====================
|
||
|
||
|
||
class QuestionAnsweringEvaluator:
|
||
"""Evaluates question answering performance using ReMeLight."""
|
||
|
||
def __init__(
|
||
self,
|
||
memory_processor: ReMeLightMemoryProcessor,
|
||
eval_model_name: str = "qwen3-max",
|
||
):
|
||
self.memory_processor = memory_processor
|
||
self.eval_model_name = eval_model_name
|
||
|
||
async def evaluate_questions(
|
||
self,
|
||
reme: ReMeLight,
|
||
questions: list[dict],
|
||
user_name: str, # pylint: disable=unused-argument
|
||
uuid: str,
|
||
top_k: int = 20,
|
||
) -> list[dict]:
|
||
"""Evaluate all questions for one user."""
|
||
results = []
|
||
|
||
total = len(questions)
|
||
for qi, qa in enumerate(questions):
|
||
if qa.get("category") == 5:
|
||
continue
|
||
|
||
logger.info(f" QA {qi+1}/{total}: {qa['question'][:80]}...")
|
||
print(f" QA {qi+1}/{total}: {qa['question'][:60]}...", flush=True)
|
||
answer_dict, raw_results, duration_ms = await self.memory_processor.search_memory(
|
||
reme=reme,
|
||
query=qa["question"],
|
||
top_k=top_k,
|
||
)
|
||
|
||
system_answer = answer_dict.get("answer", "")
|
||
system_reasoning = answer_dict.get("reasoning", "")
|
||
|
||
# Evaluate LLM-generated answer
|
||
eval_result = await _evaluation_for_question(
|
||
reme=reme,
|
||
question=qa["question"],
|
||
golden_answer=qa["answer"],
|
||
generated_answer=system_answer,
|
||
_model_name=self.eval_model_name,
|
||
)
|
||
|
||
# Also evaluate raw search results
|
||
raw_memories_text = _format_search_results_for_prompt(raw_results)
|
||
eval_raw = await _evaluation_for_question(
|
||
reme=reme,
|
||
question=qa["question"],
|
||
golden_answer=qa["answer"],
|
||
generated_answer=raw_memories_text,
|
||
_model_name=self.eval_model_name,
|
||
)
|
||
|
||
qa_result = {
|
||
**qa,
|
||
"uuid": uuid,
|
||
"system_response": system_answer,
|
||
"system_reasoning": system_reasoning,
|
||
"retrieved_memories": raw_memories_text,
|
||
"raw_search_results": raw_results,
|
||
"search_duration_ms": duration_ms,
|
||
"result_type": eval_result.get("evaluation_result"),
|
||
"question_answering_reasoning": eval_result.get("reasoning", ""),
|
||
"original_result_type": eval_raw.get("evaluation_result"),
|
||
"original_question_answering_reasoning": eval_raw.get("reasoning", ""),
|
||
}
|
||
results.append(qa_result)
|
||
|
||
return results
|
||
|
||
|
||
class MetricsAggregator:
|
||
"""Aggregates evaluation metrics (same as vector-based eval)."""
|
||
|
||
@staticmethod
|
||
def _compute_single_metric(
|
||
qa_records: list[dict],
|
||
result_key: str,
|
||
) -> dict[str, Any]:
|
||
total = len(qa_records)
|
||
if total == 0:
|
||
return {
|
||
"correct_qa_ratio(all)": 0,
|
||
"correct_qa_ratio(valid)": 0,
|
||
"qa_valid_num": 0,
|
||
"qa_num": 0,
|
||
"category_1_accuracy": 0.0,
|
||
"category_2_accuracy": 0.0,
|
||
"category_3_accuracy": 0.0,
|
||
"category_4_accuracy": 0.0,
|
||
}
|
||
|
||
correct = 0
|
||
valid = 0
|
||
cat_correct = {1: 0, 2: 0, 3: 0, 4: 0}
|
||
cat_total = {1: 0, 2: 0, 3: 0, 4: 0}
|
||
|
||
for qa in qa_records:
|
||
cat = qa.get("category", 0)
|
||
if cat in cat_total:
|
||
cat_total[cat] += 1
|
||
|
||
result_type = qa.get(result_key, "")
|
||
if result_type is not None and cat in (1, 2, 3, 4):
|
||
valid += 1
|
||
if result_type is True:
|
||
correct += 1
|
||
if cat in cat_correct:
|
||
cat_correct[cat] += 1
|
||
|
||
metrics = {
|
||
"correct_qa_ratio(all)": correct / total,
|
||
"correct_qa_ratio(valid)": correct / valid if valid > 0 else 0,
|
||
"qa_valid_num": valid,
|
||
"qa_num": total,
|
||
}
|
||
for cat in (1, 2, 3, 4):
|
||
metrics[f"category_{cat}_accuracy"] = cat_correct[cat] / cat_total[cat] if cat_total[cat] > 0 else 0.0
|
||
|
||
return metrics
|
||
|
||
@staticmethod
|
||
def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
|
||
"""Compute QA accuracy metrics grouped by evaluation type."""
|
||
return {
|
||
"with_llm_answer": MetricsAggregator._compute_single_metric(
|
||
qa_records,
|
||
"result_type",
|
||
),
|
||
"with_original_memories": MetricsAggregator._compute_single_metric(
|
||
qa_records,
|
||
"original_result_type",
|
||
),
|
||
}
|
||
|
||
@staticmethod
|
||
def compute_time_metrics(eval_results_file: str) -> dict[str, float]:
|
||
"""Compute time-based metrics from evaluation results."""
|
||
add_duration = 0
|
||
search_duration = 0
|
||
|
||
with open(eval_results_file, "r", encoding="utf-8") as f:
|
||
for line in f:
|
||
if not line.strip():
|
||
continue
|
||
user_data = json.loads(line)
|
||
for session in user_data.get("sessions", []):
|
||
add_duration += session.get("add_dialogue_duration_ms", 0)
|
||
for qa in user_data.get("evaluation_results", {}).get(
|
||
"question_answering_records",
|
||
[],
|
||
):
|
||
search_duration += qa.get("search_duration_ms", 0)
|
||
|
||
return {
|
||
"add_dialogue_duration_time": add_duration / 1000 / 60,
|
||
"search_memory_duration_time": search_duration / 1000 / 60,
|
||
"total_duration_time": (add_duration + search_duration) / 1000 / 60,
|
||
}
|
||
|
||
|
||
def parse_locomo_timestamp(timestamp_str: str) -> datetime | None:
|
||
"""Parse LoCoMo timestamp format: '6:07 pm on 13 January, 2023'."""
|
||
timestamp_str = timestamp_str.replace("\\s+", " ").strip()
|
||
|
||
if timestamp_str.lower() == "unknown" or not timestamp_str:
|
||
return None
|
||
|
||
try:
|
||
return datetime.strptime(timestamp_str, "%I:%M %p on %d %B, %Y")
|
||
except ValueError:
|
||
logger.warning(f"Failed to parse timestamp: {timestamp_str}")
|
||
return None
|
||
|
||
|
||
# ==================== Main Evaluator ====================
|
||
|
||
|
||
class LocomoReMeLightEvaluator:
|
||
"""Main evaluation orchestrator for ReMeLight on LoCoMo benchmark."""
|
||
|
||
def __init__(self, config: EvalConfig):
|
||
self.config = config
|
||
self.file_manager = FileManager(config.output_dir)
|
||
self.memory_processor = ReMeLightMemoryProcessor(
|
||
eval_model_name=config.eval_model_name,
|
||
index_wait_seconds=config.index_wait_seconds,
|
||
)
|
||
self.qa_evaluator = QuestionAnsweringEvaluator(
|
||
memory_processor=self.memory_processor,
|
||
eval_model_name=config.eval_model_name,
|
||
)
|
||
self.data_loader = DataLoader()
|
||
self._update_lock: asyncio.Lock | None = None
|
||
self._output_file: str | None = None
|
||
self._reme_instances: list[ReMeLight] = []
|
||
|
||
async def create_reme(self, working_dir: str) -> ReMeLight:
|
||
"""Create a ReMeLight instance with eval configuration."""
|
||
reme = ReMeLight(
|
||
working_dir=working_dir, # 每个用户独立目录
|
||
default_as_llm_config={ # 摘要用的 LLM
|
||
"model_name": self.config.reme_model_name,
|
||
"backend": "openai",
|
||
"stream": False,
|
||
},
|
||
default_embedding_model_config={ # embedding
|
||
"model_name": "text-embedding-v4",
|
||
"backend": "openai",
|
||
},
|
||
default_file_store_config={ # 开启混合搜索
|
||
"fts_enabled": True,
|
||
"vector_enabled": True,
|
||
},
|
||
enable_load_env=True,
|
||
)
|
||
await reme.start()
|
||
self._reme_instances.append(reme)
|
||
return reme
|
||
|
||
# 单个用户的完整评测流水线
|
||
async def process_user(self, user_data: dict) -> dict:
|
||
"""Process all sessions for one user conversation."""
|
||
conv = user_data["conversation"]
|
||
speaker_a = conv["speaker_a"]
|
||
speaker_b = conv["speaker_b"]
|
||
uuid = f"{speaker_a}_{speaker_b}"
|
||
user_name = [speaker_a, speaker_b]
|
||
user_file_name = f"{speaker_a}_{speaker_b}"
|
||
|
||
working_dir = str(
|
||
Path(self.config.output_dir) / "working_dirs" / user_file_name,
|
||
)
|
||
|
||
if self.config.resume:
|
||
if not Path(working_dir).exists():
|
||
logger.error(f"Resume mode: working_dir not found: {working_dir}")
|
||
return {"uuid": uuid, "user_name": user_file_name, "status": "no_working_dir"}
|
||
logger.info(f"Resume mode: reusing {working_dir}")
|
||
else:
|
||
if Path(working_dir).exists():
|
||
shutil.rmtree(working_dir)
|
||
|
||
# 初始化 ReMeLight,配 LLM + Embedding + FileWatcher
|
||
reme = await self.create_reme(working_dir)
|
||
|
||
session_num = 19 if uuid == "Caroline_Melanie" else int(len(conv) / 2 - 1)
|
||
time_interval = 60
|
||
|
||
logger.info(
|
||
f"Processing user {user_name}: {session_num} sessions, " f"working_dir={working_dir}",
|
||
)
|
||
|
||
if not self.config.resume:
|
||
# 循环 19 个 session: 调 summary_memory() 写 memory/*.md
|
||
for idx in range(session_num):
|
||
logger.info(
|
||
f" Session {idx + 1}/{session_num} for {user_file_name}",
|
||
)
|
||
session_data = {
|
||
"uuid": uuid,
|
||
"user_name": user_file_name,
|
||
"timestamp": conv[f"session_{idx + 1}_date_time"],
|
||
"session": conv[f"session_{idx + 1}"],
|
||
}
|
||
|
||
dialogue = conv[f"session_{idx + 1}"]
|
||
base_timestamp = parse_locomo_timestamp(session_data["timestamp"])
|
||
if base_timestamp is None:
|
||
base_timestamp = datetime(2023, 1, 1)
|
||
|
||
formatted_messages = self.data_loader.format_dialogue_messages(
|
||
dialogue,
|
||
speaker_a,
|
||
base_timestamp,
|
||
time_interval,
|
||
)
|
||
|
||
# 调 summary_memory() 写 memory/*.md
|
||
summary_text, duration_ms = await self.memory_processor.add_memories(
|
||
reme=reme,
|
||
messages=formatted_messages,
|
||
batch_size=self.config.batch_size,
|
||
)
|
||
|
||
session_data.update(
|
||
{
|
||
"dialogue": dialogue,
|
||
"summary_text": summary_text,
|
||
"add_dialogue_duration_ms": duration_ms,
|
||
},
|
||
)
|
||
|
||
self.file_manager.save_session(user_file_name, idx, session_data)
|
||
|
||
qas = user_data.get("qa", [])
|
||
# 逐条 QA:搜记忆 → LLM 回答 → LLM 裁判
|
||
qa_results = await self.qa_evaluator.evaluate_questions(
|
||
reme=reme,
|
||
questions=qas,
|
||
user_name=user_file_name,
|
||
uuid=uuid,
|
||
top_k=self.config.top_k,
|
||
)
|
||
|
||
question_file = self.file_manager.get_user_dir(user_file_name) / "questions.json"
|
||
with open(question_file, "w", encoding="utf-8") as f:
|
||
json.dump({"question_answering_records": qa_results}, f, ensure_ascii=False, indent=2)
|
||
|
||
await reme.close()
|
||
|
||
return {"uuid": uuid, "user_name": user_file_name, "status": "ok"}
|
||
|
||
# 整个评测的调度中心
|
||
async def run_evaluation(self):
|
||
"""Run the complete evaluation pipeline."""
|
||
start_time = time.time()
|
||
|
||
all_users = self.data_loader.load_json(self.config.data_path)
|
||
users_to_process = all_users[: self.config.user_num]
|
||
|
||
print("\n" + "=" * 80)
|
||
print("LOCOMO EVALUATION - ReMeLight (FILE-BASED)")
|
||
print(f"Users: {len(users_to_process)} | Concurrency: {self.config.max_concurrency}")
|
||
print(f"Output: {self.config.output_dir}")
|
||
print("=" * 80 + "\n")
|
||
|
||
self._output_file = os.path.join(self.config.output_dir, "eval_results.jsonl")
|
||
self._update_lock = asyncio.Lock()
|
||
|
||
semaphore = asyncio.Semaphore(self.config.max_concurrency)
|
||
|
||
async def process_with_cache(idx: int, user_data: dict):
|
||
async with semaphore:
|
||
user_name = f"{user_data['conversation']['speaker_a']}_" f"{user_data['conversation']['speaker_b']}"
|
||
|
||
if self.file_manager.user_has_cache(user_name):
|
||
logger.info(f"[{idx}/{len(users_to_process)}] Skipping {user_name} (cached)")
|
||
return {"user_name": user_name, "status": "cached"}
|
||
|
||
logger.info(f"[{idx}/{len(users_to_process)}] Processing {user_name}...")
|
||
result = await self.process_user(user_data)
|
||
logger.info(f"[{idx}/{len(users_to_process)}] Completed {user_name}")
|
||
|
||
await self._trigger_update()
|
||
return result
|
||
|
||
tasks = [process_with_cache(idx, user) for idx, user in enumerate(users_to_process, 1)]
|
||
await asyncio.gather(*tasks, return_exceptions=True)
|
||
|
||
elapsed = time.time() - start_time
|
||
print(f"\nProcessing completed in {elapsed:.2f}s")
|
||
print(f"Results: {self._output_file}\n")
|
||
|
||
await self._aggregate_and_report(self._output_file)
|
||
|
||
async def _trigger_update(self):
|
||
if self._update_lock is None or self._output_file is None:
|
||
return
|
||
async with self._update_lock:
|
||
self.file_manager.combine_results(self._output_file)
|
||
self._update_statistics(self._output_file)
|
||
|
||
def _update_statistics(self, results_file: str):
|
||
if not os.path.exists(results_file):
|
||
return
|
||
|
||
qa_records = []
|
||
try:
|
||
with open(results_file, "r", encoding="utf-8") as f:
|
||
for line in f:
|
||
if not line.strip():
|
||
continue
|
||
user_data = json.loads(line)
|
||
eval_results = user_data.get("evaluation_results", {})
|
||
qa_records.extend(
|
||
eval_results.get("question_answering_records", []),
|
||
)
|
||
except (json.JSONDecodeError, KeyError):
|
||
return
|
||
|
||
if not qa_records:
|
||
return
|
||
|
||
qa_metrics = MetricsAggregator.compute_qa_metrics(qa_records)
|
||
time_metrics = MetricsAggregator.compute_time_metrics(results_file)
|
||
|
||
final_results = {
|
||
"overall_score": {
|
||
"question_answering": qa_metrics,
|
||
"time_consuming": time_metrics,
|
||
},
|
||
}
|
||
|
||
report_file = os.path.join(self.config.output_dir, "eval_statistics.json")
|
||
with open(report_file, "w", encoding="utf-8") as f:
|
||
json.dump(final_results, f, ensure_ascii=False, indent=4)
|
||
|
||
async def _aggregate_and_report(self, results_file: str):
|
||
print("=" * 80)
|
||
print("AGGREGATING METRICS")
|
||
print("=" * 80 + "\n")
|
||
|
||
qa_records = []
|
||
with open(results_file, "r", encoding="utf-8") as f:
|
||
for line in f:
|
||
if not line.strip():
|
||
continue
|
||
user_data = json.loads(line)
|
||
eval_results = user_data.get("evaluation_results", {})
|
||
qa_records.extend(
|
||
eval_results.get("question_answering_records", []),
|
||
)
|
||
|
||
qa_metrics = MetricsAggregator.compute_qa_metrics(qa_records)
|
||
time_metrics = MetricsAggregator.compute_time_metrics(results_file)
|
||
|
||
final_results = {
|
||
"overall_score": {
|
||
"question_answering": qa_metrics,
|
||
"time_consuming": time_metrics,
|
||
},
|
||
"question_answering_records": qa_records,
|
||
}
|
||
|
||
report_file = os.path.join(self.config.output_dir, "eval_statistics.json")
|
||
with open(report_file, "w", encoding="utf-8") as f:
|
||
json.dump(final_results, f, ensure_ascii=False, indent=4)
|
||
|
||
self._print_summary(qa_metrics, time_metrics)
|
||
|
||
def _print_summary(self, qa_metrics: dict, time_metrics: dict):
|
||
print("=" * 80)
|
||
print("EVALUATION SUMMARY - ReMeLight (FILE-BASED)")
|
||
print("=" * 80 + "\n")
|
||
|
||
llm_metrics = qa_metrics["with_llm_answer"]
|
||
print("Question Answering (with LLM answer):")
|
||
print(f" Correct (all): {llm_metrics['correct_qa_ratio(all)']:.4f}")
|
||
print(f" Correct (valid): {llm_metrics['correct_qa_ratio(valid)']:.4f}")
|
||
print(f" Valid/Total: {llm_metrics['qa_valid_num']}/{llm_metrics['qa_num']}")
|
||
print(f" Category 1 Accuracy: {llm_metrics['category_1_accuracy']:.4f}")
|
||
print(f" Category 2 Accuracy: {llm_metrics['category_2_accuracy']:.4f}")
|
||
print(f" Category 3 Accuracy: {llm_metrics['category_3_accuracy']:.4f}")
|
||
print(f" Category 4 Accuracy: {llm_metrics['category_4_accuracy']:.4f}")
|
||
|
||
orig_metrics = qa_metrics["with_original_memories"]
|
||
print("\nQuestion Answering (with original memories):")
|
||
print(f" Correct (all): {orig_metrics['correct_qa_ratio(all)']:.4f}")
|
||
print(f" Correct (valid): {orig_metrics['correct_qa_ratio(valid)']:.4f}")
|
||
print(f" Valid/Total: {orig_metrics['qa_valid_num']}/{orig_metrics['qa_num']}")
|
||
|
||
print("\nTime Metrics:")
|
||
print(f" Memory Addition: {time_metrics['add_dialogue_duration_time']:.2f} min")
|
||
print(f" Memory Search: {time_metrics['search_memory_duration_time']:.2f} min")
|
||
print(f" Total: {time_metrics['total_duration_time']:.2f} min")
|
||
print("\n" + "=" * 80)
|
||
|
||
|
||
# ==================== Main ====================
|
||
|
||
|
||
async def main_async(
|
||
data_path: str,
|
||
top_k: int = 20,
|
||
user_num: int = 1,
|
||
max_concurrency: int = 2,
|
||
reme_model_name: str = "qwen-flash",
|
||
eval_model_name: str = "qwen3-max",
|
||
output_dir: str = "bench_results/reme_light",
|
||
index_wait_seconds: int = 5,
|
||
resume: bool = False,
|
||
):
|
||
"""Async entry point for the LoCoMo ReMeLight evaluation."""
|
||
config = EvalConfig(
|
||
data_path=data_path,
|
||
top_k=top_k,
|
||
user_num=user_num,
|
||
max_concurrency=max_concurrency,
|
||
reme_model_name=reme_model_name,
|
||
eval_model_name=eval_model_name,
|
||
output_dir=output_dir,
|
||
index_wait_seconds=index_wait_seconds,
|
||
resume=resume,
|
||
)
|
||
|
||
evaluator = LocomoReMeLightEvaluator(config)
|
||
await evaluator.run_evaluation()
|
||
|
||
|
||
def main(
|
||
data_path: str,
|
||
top_k: int = 20,
|
||
user_num: int = 1,
|
||
max_concurrency: int = 2,
|
||
reme_model_name: str = "qwen-flash",
|
||
eval_model_name: str = "qwen3-max",
|
||
output_dir: str = "bench_results/reme_light",
|
||
index_wait_seconds: int = 5,
|
||
resume: bool = False,
|
||
):
|
||
"""Entry point for the LoCoMo ReMeLight evaluation."""
|
||
asyncio.run(
|
||
main_async(
|
||
data_path=data_path,
|
||
top_k=top_k,
|
||
user_num=user_num,
|
||
max_concurrency=max_concurrency,
|
||
reme_model_name=reme_model_name,
|
||
eval_model_name=eval_model_name,
|
||
output_dir=output_dir,
|
||
index_wait_seconds=index_wait_seconds,
|
||
resume=resume,
|
||
),
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import argparse
|
||
|
||
parser = argparse.ArgumentParser(
|
||
description="ReMeLight (file-based) evaluation on LoCoMo benchmark",
|
||
)
|
||
parser.add_argument(
|
||
"--data_path",
|
||
type=str,
|
||
default="benchmark/locomo/data/locomo10.json",
|
||
help="Path to LoCoMo data file (default: benchmark/locomo/data/locomo10.json)",
|
||
)
|
||
parser.add_argument(
|
||
"--top_k",
|
||
type=int,
|
||
default=20,
|
||
help="Max memory search results (default: 20)",
|
||
)
|
||
parser.add_argument(
|
||
"--user_num",
|
||
type=int,
|
||
default=1,
|
||
help="Number of users to evaluate",
|
||
)
|
||
parser.add_argument(
|
||
"--max_concurrency",
|
||
type=int,
|
||
default=2,
|
||
help="Max concurrent users",
|
||
)
|
||
parser.add_argument(
|
||
"--reme_model_name",
|
||
type=str,
|
||
default="qwen-flash",
|
||
help="Model for ReMeLight summarization",
|
||
)
|
||
parser.add_argument(
|
||
"--eval_model_name",
|
||
type=str,
|
||
default="qwen3-max",
|
||
help="Model for LLM-as-Judge evaluation",
|
||
)
|
||
parser.add_argument(
|
||
"--output_dir",
|
||
type=str,
|
||
default="bench_results/reme_light",
|
||
help="Output directory for results",
|
||
)
|
||
parser.add_argument(
|
||
"--index_wait_seconds",
|
||
type=int,
|
||
default=5,
|
||
help="Seconds to wait for FileWatcher re-indexing after summarization",
|
||
)
|
||
parser.add_argument(
|
||
"--resume",
|
||
action="store_true",
|
||
default=False,
|
||
help="Skip summarization, go straight to QA (reuse existing working_dir)",
|
||
)
|
||
|
||
args = parser.parse_args()
|
||
print(f"Args: {args}")
|
||
|
||
# Check if data file exists, print helpful instructions if not
|
||
if not os.path.exists(args.data_path):
|
||
print(f"\n Data file not found: {args.data_path}\n")
|
||
print("To download the LoCoMo dataset:")
|
||
print(" mkdir -p benchmark/locomo/data")
|
||
print(" git clone https://github.com/luyanhexay/locomo-dynamemory.git /tmp/locomo-dynamemory")
|
||
print(" cp /tmp/locomo-dynamemory/data/locomo10.json benchmark/locomo/data/\n")
|
||
print("Or specify a custom path:")
|
||
print(" python benchmark/locomo/eval_reme_light.py --data_path /path/to/locomo10.json\n")
|
||
import sys
|
||
|
||
sys.exit(1)
|
||
|
||
main(
|
||
data_path=args.data_path,
|
||
top_k=args.top_k,
|
||
user_num=args.user_num,
|
||
max_concurrency=args.max_concurrency,
|
||
reme_model_name=args.reme_model_name,
|
||
eval_model_name=args.eval_model_name,
|
||
output_dir=args.output_dir,
|
||
index_wait_seconds=args.index_wait_seconds,
|
||
resume=args.resume,
|
||
)
|