refactor(core): move core module to core_old and update import paths

This commit is contained in:
jinli.yl 2026-01-21 16:34:09 +08:00
parent c3c7b5a4a0
commit c7fc8255b1
216 changed files with 2190 additions and 2400 deletions

View file

@ -3,7 +3,7 @@ repos:
rev: v6.0.0
hooks:
- id: check-ast
exclude: ^(test/|cookbook/)
exclude: ^(test/|cookbook/|reme_ai/core_old/|reme_ai/mem_agent/|reme_ai/mem_tool/|bench)
- id: check-yaml
- id: check-xml
- id: check-toml
@ -14,18 +14,18 @@ repos:
rev: v4.0.0
hooks:
- id: add-trailing-comma
exclude: ^(test/|cookbook/)
exclude: ^(test/|cookbook/|reme_ai/core_old/|reme_ai/mem_agent/|reme_ai/mem_tool/|bench)
- repo: https://github.com/psf/black
rev: 25.9.0
hooks:
- id: black
exclude: ^(test/|cookbook/)
exclude: ^(test/|cookbook/|reme_ai/core_old/|reme_ai/mem_agent/|reme_ai/mem_tool/|bench)
args: [--line-length=120]
- repo: https://github.com/PyCQA/flake8
rev: 7.3.0
hooks:
- id: flake8
exclude: ^(test/|cookbook/)
exclude: ^(test/|cookbook/|reme_ai/core_old/|reme_ai/mem_agent/|reme_ai/mem_tool/|bench)
args: [
"--extend-ignore=E203",
"--max-line-length=120"
@ -44,6 +44,10 @@ repos:
| \.demo$
| \.md$
| \.html$
| reme_ai/core_old/
| reme_ai/mem_agent/
| reme_ai/mem_tool/
| bench
)
args: [
--disable=W0511,

View file

@ -10,8 +10,8 @@ from datetime import datetime, timezone
from tqdm import tqdm
from reme_ai.core.enumeration import Role
from reme_ai.core.schema import Message, MemoryNode
from reme_ai.core_old.enumeration import Role
from reme_ai.core_old.schema import Message, MemoryNode
from reme_ai.reme import ReMe
TEMPLATE_REME = """Memories for user {user_id}:

View file

@ -38,29 +38,29 @@ class DatasetStats:
total_users: int
total_sessions: int
total_dialogues: int
avg_sessions_per_user: float
avg_dialogues_per_session: float
avg_dialogue_length_per_session: float
# 详细分布
sessions_per_user_list: list[int]
dialogues_per_session_list: list[int]
dialogue_lengths_per_session_list: list[int]
# Content 统计
total_contents: int # 所有对话回合的 content 总数
content_sizes: list[int] # 每个 content 的大小(字符数)
min_content_size: int
max_content_size: int
percentiles: dict[str, float] # 分位点统计(全部)
# 按 role 分类的 Content 统计
total_user_contents: int
total_assistant_contents: int
user_percentiles: dict[str, float] # user 角色的分位点
assistant_percentiles: dict[str, float] # assistant 角色的分位点
# Session 分割统计
total_chunks_after_split: int # 按 5000 字符分割后的总 chunk 数
chunks_per_user_list: list[int] # 每个用户分割后的 chunk 数量
@ -69,14 +69,14 @@ class DatasetStats:
class DatasetAnalyzer:
"""数据集分析器"""
def __init__(self, data_path: str):
self.data_path = data_path
self.user_stats_list: list[UserStats] = []
self.all_content_sizes: list[int] = [] # 收集所有 content 的大小
self.user_content_sizes: list[int] = [] # user 角色的 content 大小
self.assistant_content_sizes: list[int] = [] # assistant 角色的 content 大小
@staticmethod
def extract_user_name(persona_info: str) -> str:
"""从 persona_info 中提取用户名"""
@ -84,7 +84,7 @@ class DatasetAnalyzer:
if not match:
return "Unknown"
return match.group(1).strip()
@staticmethod
def calculate_dialogue_length(dialogue: list[dict]) -> int:
"""计算对话的总长度(字符数)"""
@ -93,7 +93,7 @@ class DatasetAnalyzer:
content = turn.get("content", "")
total_length += len(content)
return total_length
@staticmethod
def split_session_into_chunks(dialogue: list[dict], max_length: int = 5000) -> int:
"""
@ -102,23 +102,23 @@ class DatasetAnalyzer:
1. 每次添加 2 个对话回合user-assistant
2. 如果添加后超过 max_length就开始新的 chunk
3. 但是每个 chunk 至少包含 2 个对话回合
返回分割后的 chunk 数量
"""
if not dialogue:
return 0
chunks = []
current_chunk = []
current_length = 0
# 每次处理 2 个对话回合
i = 0
while i < len(dialogue):
# 取 2 个对话回合(如果不足 2 个,取剩余的)
pair = dialogue[i:i+2]
pair_length = sum(len(turn.get("content", "")) for turn in pair)
# 如果当前 chunk 为空,直接添加(保证至少 2 个)
if not current_chunk:
current_chunk.extend(pair)
@ -137,72 +137,72 @@ class DatasetAnalyzer:
current_chunk.extend(pair)
current_length += pair_length
i += len(pair)
# 添加最后一个 chunk
if current_chunk:
chunks.append(current_chunk)
return len(chunks)
def load_and_analyze(self):
"""加载并分析数据集"""
logger.info(f"Loading data from: {self.data_path}")
with open(self.data_path, "r", encoding="utf-8") as f:
for line_num, line in enumerate(f, 1):
if not line.strip():
continue
try:
user_data = json.loads(line)
self._analyze_user(user_data)
except json.JSONDecodeError as e:
logger.error(f"Error parsing line {line_num}: {e}")
continue
logger.info(f"Analyzed {len(self.user_stats_list)} users")
def _analyze_user(self, user_data: dict):
"""分析单个用户的数据"""
user_name = self.extract_user_name(user_data.get("persona_info", ""))
uuid = user_data.get("uuid", "")
sessions = user_data.get("sessions", [])
dialogues_per_session = []
dialogue_lengths_per_session = []
session_time_ranges = []
total_chunks = 0
for session in sessions:
dialogue = session.get("dialogue", [])
num_dialogues = len(dialogue)
dialogue_length = self.calculate_dialogue_length(dialogue)
dialogues_per_session.append(num_dialogues)
dialogue_lengths_per_session.append(dialogue_length)
# 收集 session 的时间范围
start_time = session.get("start_time", None)
end_time = session.get("end_time", None)
session_time_ranges.append((start_time, end_time))
# 计算这个 session 分割后的 chunk 数量
num_chunks = self.split_session_into_chunks(dialogue, max_length=5000)
total_chunks += num_chunks
# 收集每个 content 的大小,并按 role 分类
for turn in dialogue:
content = turn.get("content", "")
content_size = len(content)
role = turn.get("role", "")
self.all_content_sizes.append(content_size)
if role == "user":
self.user_content_sizes.append(content_size)
elif role == "assistant":
self.assistant_content_sizes.append(content_size)
user_stats = UserStats(
user_name=user_name,
uuid=uuid,
@ -212,25 +212,25 @@ class DatasetAnalyzer:
num_chunks_after_split=total_chunks,
session_time_ranges=session_time_ranges
)
self.user_stats_list.append(user_stats)
def compute_dataset_stats(self) -> DatasetStats:
"""计算整体数据集统计"""
total_users = len(self.user_stats_list)
sessions_per_user_list = [u.num_sessions for u in self.user_stats_list]
total_sessions = sum(sessions_per_user_list)
dialogues_per_session_list = []
dialogue_lengths_per_session_list = []
for user in self.user_stats_list:
dialogues_per_session_list.extend(user.dialogues_per_session)
dialogue_lengths_per_session_list.extend(user.dialogue_lengths_per_session)
total_dialogues = sum(dialogues_per_session_list)
# 计算平均值
avg_sessions_per_user = total_sessions / total_users if total_users > 0 else 0
avg_dialogues_per_session = (
@ -240,43 +240,43 @@ class DatasetAnalyzer:
sum(dialogue_lengths_per_session_list) / len(dialogue_lengths_per_session_list)
if dialogue_lengths_per_session_list else 0
)
# Content 统计
total_contents = len(self.all_content_sizes)
min_content_size = min(self.all_content_sizes) if self.all_content_sizes else 0
max_content_size = max(self.all_content_sizes) if self.all_content_sizes else 0
# 计算分位点 (10%, 15%, 20%, ..., 95%)
percentile_points = list(range(10, 100, 5)) # 10, 15, 20, ..., 95
# 全部 content 的分位点
percentiles = {}
if self.all_content_sizes:
content_array = np.array(self.all_content_sizes)
for p in percentile_points:
percentiles[f"p{p}"] = float(np.percentile(content_array, p))
# user 角色的分位点
user_percentiles = {}
if self.user_content_sizes:
user_array = np.array(self.user_content_sizes)
for p in percentile_points:
user_percentiles[f"p{p}"] = float(np.percentile(user_array, p))
# assistant 角色的分位点
assistant_percentiles = {}
if self.assistant_content_sizes:
assistant_array = np.array(self.assistant_content_sizes)
for p in percentile_points:
assistant_percentiles[f"p{p}"] = float(np.percentile(assistant_array, p))
# Session 分割统计
chunks_per_user_list = [u.num_chunks_after_split for u in self.user_stats_list]
total_chunks_after_split = sum(chunks_per_user_list)
avg_chunks_per_user = (
total_chunks_after_split / total_users if total_users > 0 else 0
)
return DatasetStats(
total_users=total_users,
total_sessions=total_sessions,
@ -300,16 +300,16 @@ class DatasetAnalyzer:
chunks_per_user_list=chunks_per_user_list,
avg_chunks_per_user=avg_chunks_per_user
)
@staticmethod
def _print_percentiles(percentiles: dict[str, float]):
"""打印分位点统计(辅助函数)"""
if not percentiles:
print(" (无数据)")
return
sorted_percentiles = sorted(percentiles.keys(), key=lambda x: int(x[1:]))
# 每行显示 5 个分位点,让输出更紧凑
for i in range(0, len(sorted_percentiles), 5):
line_items = []
@ -318,36 +318,36 @@ class DatasetAnalyzer:
p_num = percentile_key[1:] # 去掉 'p' 前缀
line_items.append(f"{p_num}%: {percentile_value:.0f}")
print(f" {' | '.join(line_items)}")
def print_summary(self, stats: DatasetStats):
"""打印统计摘要"""
print("\n" + "=" * 80)
print("HALUMEM DATASET STATISTICS")
print("=" * 80 + "\n")
print("📊 总体统计:")
print(f" 总用户数: {stats.total_users}")
print(f" 总 Session 数: {stats.total_sessions}")
print(f" 总对话数: {stats.total_dialogues}")
print(f"\n📈 平均值:")
print(f" 每个用户的平均 Session 数: {stats.avg_sessions_per_user:.2f}")
print(f" 每个 Session 的平均对话数: {stats.avg_dialogues_per_session:.2f}")
print(f" 每个 Session 的平均对话长度(字符): {stats.avg_dialogue_length_per_session:.2f}")
print(f"\n📊 分布统计:")
if stats.sessions_per_user_list:
print(f" 每用户 Session 数 - 最小: {min(stats.sessions_per_user_list)}, "
f"最大: {max(stats.sessions_per_user_list)}")
if stats.dialogues_per_session_list:
print(f" 每 Session 对话数 - 最小: {min(stats.dialogues_per_session_list)}, "
f"最大: {max(stats.dialogues_per_session_list)}")
if stats.dialogue_lengths_per_session_list:
print(f" 每 Session 对话长度 - 最小: {min(stats.dialogue_lengths_per_session_list)}, "
f"最大: {max(stats.dialogue_lengths_per_session_list)}")
print(f"\n💬 Content 详细统计:")
print(f" 总 Content 数量: {stats.total_contents}")
print(f" User 消息数: {stats.total_user_contents}")
@ -355,34 +355,34 @@ class DatasetAnalyzer:
print(f" Content 大小(字符数):")
print(f" 最小值: {stats.min_content_size}")
print(f" 最大值: {stats.max_content_size}")
if stats.content_sizes:
avg_content_size = sum(stats.content_sizes) / len(stats.content_sizes)
print(f" 平均值: {avg_content_size:.2f}")
print(f"\n📈 Content 大小分位点 (全部):")
self._print_percentiles(stats.percentiles)
print(f"\n📈 Content 大小分位点 (User 角色):")
self._print_percentiles(stats.user_percentiles)
print(f"\n📈 Content 大小分位点 (Assistant 角色):")
self._print_percentiles(stats.assistant_percentiles)
print(f"\n✂️ Session 分割统计 (按 5000 字符分割):")
print(f" 原始 Session 总数: {stats.total_sessions}")
print(f" 分割后 Chunk 总数: {stats.total_chunks_after_split}")
print(f" 每个用户平均 Chunk 数: {stats.avg_chunks_per_user:.2f}")
print(f" Chunk/Session 比例: {stats.total_chunks_after_split / stats.total_sessions:.2f}x")
print("\n" + "=" * 80)
def print_per_user_stats(self):
"""打印每个用户的详细统计"""
print("\n" + "=" * 80)
print("PER-USER STATISTICS")
print("=" * 80 + "\n")
for idx, user_stats in enumerate(self.user_stats_list, 1):
avg_dialogues = (
sum(user_stats.dialogues_per_session) / len(user_stats.dialogues_per_session)
@ -392,50 +392,50 @@ class DatasetAnalyzer:
sum(user_stats.dialogue_lengths_per_session) / len(user_stats.dialogue_lengths_per_session)
if user_stats.dialogue_lengths_per_session else 0
)
print(f"[{idx}] {user_stats.user_name} (UUID: {user_stats.uuid[:8]}...)")
print(f" Session 数: {user_stats.num_sessions}")
print(f" 分割后 Chunk 数: {user_stats.num_chunks_after_split}")
print(f" 平均每 Session 对话数: {avg_dialogues:.2f}")
print(f" 平均每 Session 对话长度: {avg_length:.2f} 字符")
print()
def print_first_user_session_times(self):
"""打印第一个用户的每个 session 的时间范围"""
if not self.user_stats_list:
print("\n没有用户数据")
return
first_user = self.user_stats_list[0]
print("\n" + "=" * 80)
print(f"第一个用户的 Session 时间统计")
print("=" * 80 + "\n")
print(f"用户名: {first_user.user_name}")
print(f"UUID: {first_user.uuid}")
print(f"总 Session 数: {first_user.num_sessions}\n")
print("-" * 80)
print(f"{'Session #':<12} {'开始时间':<30} {'结束时间':<30}")
print("-" * 80)
for idx, (start_time, end_time) in enumerate(first_user.session_time_ranges, 1):
start_str = str(start_time) if start_time is not None else ""
end_str = str(end_time) if end_time is not None else ""
print(f"{idx:<12} {start_str:<30} {end_str:<30}")
print("=" * 80)
def print_user_split_summary(self):
"""打印每个用户的分割统计摘要(表格形式)"""
print("\n" + "=" * 80)
print("PER-USER SESSION SPLIT SUMMARY (按 5000 字符分割)")
print("=" * 80 + "\n")
# 表头
print(f"{'序号':<6} {'用户名':<25} {'原始Sessions':<15} {'分割后Chunks':<15} {'比例':<10}")
print("-" * 80)
# 每个用户的数据
for idx, user_stats in enumerate(self.user_stats_list, 1):
ratio = (
@ -444,17 +444,17 @@ class DatasetAnalyzer:
)
print(f"{idx:<6} {user_stats.user_name[:24]:<25} {user_stats.num_sessions:<15} "
f"{user_stats.num_chunks_after_split:<15} {ratio:.2f}x")
print("-" * 80)
# 总计
total_sessions = sum(u.num_sessions for u in self.user_stats_list)
total_chunks = sum(u.num_chunks_after_split for u in self.user_stats_list)
overall_ratio = total_chunks / total_sessions if total_sessions > 0 else 0
print(f"{'总计':<6} {'':<25} {total_sessions:<15} {total_chunks:<15} {overall_ratio:.2f}x")
print("=" * 80)
def save_results(self, output_path: str, stats: DatasetStats):
"""保存统计结果到 JSON 文件"""
results = {
@ -512,10 +512,10 @@ class DatasetAnalyzer:
for u in self.user_stats_list
]
}
with open(output_path, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
logger.info(f"Results saved to: {output_path}")
@ -525,27 +525,27 @@ def main(data_path: str, output_path: str = None, show_per_user: bool = False):
if not Path(data_path).exists():
logger.error(f"File not found: {data_path}")
return
# 创建分析器并执行分析
analyzer = DatasetAnalyzer(data_path)
analyzer.load_and_analyze()
# 计算统计数据
stats = analyzer.compute_dataset_stats()
# 打印摘要
analyzer.print_summary(stats)
# 打印第一个用户的 session 时间统计
analyzer.print_first_user_session_times()
# 打印每个用户的分割统计摘要(始终显示)
analyzer.print_user_split_summary()
# 打印每个用户的详细统计(可选)
if show_per_user:
analyzer.print_per_user_stats()
# 保存结果到文件
if output_path:
analyzer.save_results(output_path, stats)
@ -557,7 +557,7 @@ def main(data_path: str, output_path: str = None, show_per_user: bool = False):
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="Analyze HaluMem dataset statistics"
)
@ -578,9 +578,9 @@ if __name__ == "__main__":
action="store_true",
help="Show detailed statistics for each user"
)
args = parser.parse_args()
main(
data_path=args.data_path,
output_path=args.output_path,

View file

@ -13,73 +13,73 @@ from typing import Dict, List, Tuple
def analyze_results(tmp_dir: str = "bench_results/reme_simple/tmp"):
"""
分析评估结果目录
Args:
tmp_dir: 临时结果目录路径
"""
tmp_path = Path(tmp_dir)
if not tmp_path.exists():
print(f"❌ 目录不存在: {tmp_dir}")
return
# 统计数据
result_counter = Counter()
non_correct_results = [] # 存储非Correct结果的详细信息
# 遍历所有用户目录
user_dirs = sorted([d for d in tmp_path.iterdir() if d.is_dir()])
if not user_dirs:
print(f"{tmp_dir} 下没有用户目录")
return
print(f"📁 找到 {len(user_dirs)} 个用户目录\n")
print("=" * 80)
print("开始分析...")
print("=" * 80 + "\n")
total_sessions = 0
total_questions = 0
# 遍历每个用户目录
for user_dir in user_dirs:
user_name = user_dir.name
# 获取该用户的所有session文件
session_files = sorted([
f for f in user_dir.iterdir()
f for f in user_dir.iterdir()
if f.name.startswith("session_") and f.suffix == ".json"
])
if not session_files:
continue
# 遍历每个session
for session_file in session_files:
try:
with open(session_file, "r", encoding="utf-8") as f:
session_data = json.load(f)
session_id = session_data.get("session_id", -1)
total_sessions += 1
# 跳过生成的QA session
if session_data.get("is_generated_qa_session", False):
continue
# 获取评估结果
eval_results = session_data.get("evaluation_results", {})
qa_records = eval_results.get("question_answering_records", [])
# 分析每个问题的结果
for qa_idx, qa_record in enumerate(qa_records):
result_type = qa_record.get("result_type", "Unknown")
# 统计result_type
result_counter[result_type] += 1
total_questions += 1
# 如果不是Correct记录详细信息
if result_type != "Correct":
non_correct_results.append({
@ -91,42 +91,42 @@ def analyze_results(tmp_dir: str = "bench_results/reme_simple/tmp"):
"answer": qa_record.get("answer", ""),
"system_response": qa_record.get("system_response", "")
})
except Exception as e:
print(f"⚠️ 读取文件失败: {session_file}, 错误: {e}")
continue
# 输出统计结果
print("\n" + "=" * 80)
print("统计结果")
print("=" * 80 + "\n")
print(f"📊 总用户数: {len(user_dirs)}")
print(f"📊 总Session数: {total_sessions}")
print(f"📊 总问题数: {total_questions}\n")
if total_questions == 0:
print("❌ 没有找到任何问题数据")
return
# 输出result_type分布
print("=" * 80)
print("Result Type 分布")
print("=" * 80 + "\n")
# 按数量降序排列
sorted_results = sorted(result_counter.items(), key=lambda x: x[1], reverse=True)
for result_type, count in sorted_results:
ratio = count / total_questions * 100
print(f" {result_type:20s}: {count:5d} ({ratio:6.2f}%)")
# 输出非Correct结果的详细信息
if non_correct_results:
print("\n" + "=" * 80)
print(f"非 Correct 结果详情 (共 {len(non_correct_results)} 条)")
print("=" * 80 + "\n")
for idx, result in enumerate(non_correct_results, 1):
print(f"[{idx}] {result['result_type']}")
print(f" 用户: {result['user_name']}")
@ -135,10 +135,10 @@ def analyze_results(tmp_dir: str = "bench_results/reme_simple/tmp"):
print(f" 正确答案: {result['answer']}")
print(f" 系统回答: {result['system_response'][:200]}{'...' if len(result['system_response']) > 200 else ''}")
print()
else:
print("\n🎉 所有问题都是 Correct!")
# 保存详细报告到文件
report_file = Path(tmp_dir).parent / "analysis_report.json"
report_data = {
@ -148,16 +148,16 @@ def analyze_results(tmp_dir: str = "bench_results/reme_simple/tmp"):
"total_questions": total_questions,
"result_type_distribution": dict(result_counter),
"result_type_ratio": {
result_type: count / total_questions
result_type: count / total_questions
for result_type, count in result_counter.items()
}
},
"non_correct_results": non_correct_results
}
with open(report_file, "w", encoding="utf-8") as f:
json.dump(report_data, f, ensure_ascii=False, indent=2)
print("=" * 80)
print(f"📄 详细报告已保存到: {report_file}")
print("=" * 80)
@ -165,7 +165,7 @@ def analyze_results(tmp_dir: str = "bench_results/reme_simple/tmp"):
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="分析 ReMe 评估结果中的 result_type 分布"
)
@ -175,6 +175,6 @@ if __name__ == "__main__":
default="bench_results/reme_simple/tmp",
help="临时结果目录路径 (默认: bench_results/reme_simple/tmp)"
)
args = parser.parse_args()
analyze_results(args.tmp_dir)

View file

@ -26,9 +26,9 @@ def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
"qa_valid_num": 0,
"qa_num": 0
}
correct = hallucination = omission = valid = 0
for qa in qa_records:
result_type = qa.get("result_type", "")
if result_type == "Correct":
@ -40,7 +40,7 @@ def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
elif result_type == "Omission":
omission += 1
valid += 1
metrics = {
"correct_qa_ratio(all)": correct / total,
"hallucination_qa_ratio(all)": hallucination / total,
@ -51,26 +51,26 @@ def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
"qa_valid_num": valid,
"qa_num": total
}
return metrics
def compute_time_metrics(results_file: str) -> dict[str, float]:
"""Compute timing metrics from evaluation results."""
add_duration = search_duration = 0
with open(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)
eval_results = session.get("evaluation_results", {})
for qa in eval_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,
@ -82,73 +82,73 @@ def load_from_tmp_dir(tmp_dir: str) -> str:
"""Load data from tmp directory and generate eval_results.jsonl file."""
tmp_path = Path(tmp_dir)
eval_results_file = tmp_path.parent / "eval_results.jsonl"
print(f"\n📁 Loading from: {tmp_dir}")
print(f"📝 Generating: {eval_results_file}")
user_dirs = [d for d in tmp_path.iterdir() if d.is_dir()]
print(f" Found {len(user_dirs)} users")
users_data = []
for user_dir in user_dirs:
session_files = sorted(
[f for f in user_dir.iterdir() if f.name.startswith("session_") and f.suffix == ".json"],
key=lambda f: int(f.stem.split("_")[1])
)
if not session_files:
continue
with open(session_files[0], "r", encoding="utf-8") as f:
first_session = json.load(f)
user_data = {
"uuid": first_session["uuid"],
"user_name": first_session["user_name"],
"sessions": []
}
for session_file in session_files:
with open(session_file, "r", encoding="utf-8") as f:
session_data = json.load(f)
session_data.pop("uuid", None)
session_data.pop("user_name", None)
user_data["sessions"].append(session_data)
users_data.append(user_data)
print(f"{user_dir.name}: {len(session_files)} sessions")
with open(eval_results_file, "w", encoding="utf-8") as f:
for user_data in users_data:
f.write(json.dumps(user_data, ensure_ascii=False) + "\n")
print(f" ✅ Generated: {eval_results_file}")
return str(eval_results_file)
def main(input_path: str):
"""Main function to compute statistics from eval results."""
if not os.path.exists(input_path):
print(f"❌ Error: Path not found: {input_path}")
return
print("\n" + "=" * 80)
print("REME V4 - QUESTION ANSWERING STATISTICS")
print("=" * 80)
# Load or generate eval_results.jsonl
if os.path.isdir(input_path):
results_file = load_from_tmp_dir(input_path)
else:
results_file = input_path
print(f"\n📁 Using: {results_file}")
# Collect QA records with metadata
qa_records = []
qa_with_metadata = []
user_count = session_count = 0
with open(results_file, "r", encoding="utf-8") as f:
for line in f:
if not line.strip():
@ -156,15 +156,15 @@ def main(input_path: str):
user_data = json.loads(line)
user_count += 1
user_name = user_data.get("user_name", "Unknown")
valid_session_idx = 0
for original_idx, session in enumerate(user_data.get("sessions", [])):
if session.get("is_generated_qa_session"):
continue
session_count += 1
eval_results = session.get("evaluation_results", {})
for qa_idx, qa in enumerate(eval_results.get("question_answering_records", [])):
qa_records.append(qa)
qa_with_metadata.append({
@ -173,22 +173,22 @@ def main(input_path: str):
"question_idx": qa_idx,
"qa_record": qa
})
valid_session_idx += 1
print(f"\n📊 Data Summary:")
print(f" Users: {user_count}")
print(f" Sessions: {session_count}")
print(f" QA Records: {len(qa_records)}")
# Compute metrics
qa_metrics = compute_qa_metrics(qa_records)
time_metrics = compute_time_metrics(results_file)
# Save results
output_dir = Path(results_file).parent
report_file = output_dir / "reme_eval_stat_result.json"
final_results = {
"overall_score": {
"question_answering": qa_metrics,
@ -196,12 +196,12 @@ def main(input_path: str):
},
"question_answering_records": qa_records
}
with open(report_file, "w", encoding="utf-8") as f:
json.dump(final_results, f, ensure_ascii=False, indent=4)
print(f"\n✅ Results saved to: {report_file}")
# Print metrics
print("\n" + "=" * 80)
print("📊 QUESTION ANSWERING METRICS")
@ -213,27 +213,27 @@ def main(input_path: str):
print(f" Hallucination (valid): {qa_metrics['hallucination_qa_ratio(valid)']:.4f}")
print(f" Omission (valid): {qa_metrics['omission_qa_ratio(valid)']:.4f}")
print(f" Valid/Total: {qa_metrics['qa_valid_num']}/{qa_metrics['qa_num']}")
print(f"\n⏱️ TIME 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 error records
print("\n" + "=" * 80)
print("❌ ERROR RECORDS (Non-Correct)")
print("=" * 80)
error_records = [r for r in qa_with_metadata if r["qa_record"].get("result_type") not in ["Correct", ""]]
if not error_records:
print("\n✅ All QA records are correct!")
else:
print(f"\nFound {len(error_records)} error records:\n")
for idx, record in enumerate(error_records, 1):
qa = record["qa_record"]
print(f"\n{'' * 80}")
print(f"❌ ERROR #{idx}")
print(f"{'' * 80}")
@ -264,19 +264,19 @@ def main(input_path: str):
print("\n".join(lines))
else:
print(f" {reason}")
print("\n" + "=" * 80)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Compute QA statistics from eval_reme_simple_v4.py results")
parser.add_argument("--results_file", type=str, help="Path to eval_results.jsonl file")
parser.add_argument("--tmp_dir", type=str, help="Path to tmp directory")
args = parser.parse_args()
if args.tmp_dir:
main(input_path=args.tmp_dir)
elif args.results_file:

View file

@ -358,7 +358,7 @@ async def main_async(tmp_dir: str):
# Determine paths
parent_dir = os.path.dirname(tmp_dir)
frame = "reme"
output_file_stage1 = os.path.join(parent_dir, f"{frame}_eval_results.jsonl")
output_file_stage2 = os.path.join(parent_dir, f"{frame}_eval_stat_result.json")
@ -392,7 +392,7 @@ async def main_async(tmp_dir: str):
# Load all users and process
user_data_list = list(enumerate(iter_jsonl(output_file_stage1), 1))
for idx, user_data in user_data_list:
uuid = user_data["uuid"]
tmp_file = os.path.join(tmp_dir2, f"{uuid}.json")

View file

@ -44,13 +44,13 @@ class EvalConfig:
class DataLoader:
"""Handles loading and parsing of HaluMem data."""
@staticmethod
def load_jsonl(file_path: str) -> list[dict]:
"""Load all entries from a JSONL file."""
with open(file_path, "r", encoding="utf-8") as f:
return [json.loads(line.strip()) for line in f if line.strip()]
@staticmethod
def extract_user_name(persona_info: str) -> str:
"""Extract user name from persona info string."""
@ -58,7 +58,7 @@ class DataLoader:
if not match:
raise ValueError(f"No name found in persona_info: {persona_info}")
return match.group(1).strip()
@staticmethod
def format_dialogue_messages(dialogue: list[dict]) -> list[dict]:
"""Format dialogue into ReMe message format."""
@ -74,7 +74,7 @@ class DataLoader:
}
for turn in dialogue
]
@staticmethod
def format_dialogue_for_eval(dialogue: list[dict], user_name: str = None) -> str:
"""Format dialogue into string for evaluation (only user messages)."""
@ -83,14 +83,14 @@ class DataLoader:
# Skip assistant messages - only include user messages
if turn['role'] != 'user':
continue
timestamp = datetime.strptime(
turn["timestamp"], "%b %d, %Y, %H:%M:%S"
).replace(tzinfo=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
# Use user_name if provided
role = user_name if user_name else 'user'
formatted_turns.append(
f"Role: {role}\n"
f"Content: {turn['content']}\n"
@ -101,29 +101,29 @@ class DataLoader:
class FileManager:
"""Manages file I/O operations."""
def __init__(self, base_dir: str):
self.base_dir = Path(base_dir)
self.tmp_dir = self.base_dir / "tmp"
self.tmp_dir.mkdir(parents=True, exist_ok=True)
def get_user_dir(self, user_name: str) -> Path:
"""Get the directory path for a user."""
user_dir = self.tmp_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 specific session."""
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 data to 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)
logger.info(f"✅ Saved session {session_id} to {file_path}")
def load_session(self, user_name: str, session_id: int) -> dict | None:
"""Load session data from file."""
file_path = self.get_session_file(user_name, session_id)
@ -131,38 +131,38 @@ class FileManager:
return None
with open(file_path, "r", encoding="utf-8") as f:
return json.load(f)
def user_has_cache(self, user_name: str) -> bool:
"""Check if user has cached results."""
user_dir = self.get_user_dir(user_name)
return any(f.name.startswith("session_") and f.suffix == ".json"
return any(f.name.startswith("session_") and f.suffix == ".json"
for f in user_dir.iterdir())
def combine_results(self, output_file: str):
"""Combine all user session files into a single JSONL file."""
with open(output_file, "w", encoding="utf-8") as f_out:
for user_dir in self.tmp_dir.iterdir():
if not user_dir.is_dir():
continue
session_files = sorted([
f for f in user_dir.iterdir()
f for f in user_dir.iterdir()
if f.name.startswith("session_") and f.suffix == ".json"
])
if not session_files:
continue
# Load first session to get user metadata
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": []
}
# Load all sessions
for session_file in session_files:
with open(session_file, "r", encoding="utf-8") as f_in:
@ -171,7 +171,7 @@ class FileManager:
session_data.pop("uuid", None)
session_data.pop("user_name", None)
user_data["sessions"].append(session_data)
f_out.write(json.dumps(user_data, ensure_ascii=False) + "\n")
@ -206,10 +206,10 @@ Please respond in JSON format with the following structure:
class BaselineQuestionAnsweringEvaluator:
"""Evaluates question answering performance using direct LLM inference (no memory system)."""
def __init__(self):
pass
async def answer_question(
self,
question: str,
@ -217,18 +217,18 @@ class BaselineQuestionAnsweringEvaluator:
) -> tuple[str, str, float]:
"""
Answer a question using the dialogue history directly.
Returns:
tuple: (answer, reasoning, duration_ms)
"""
start = time.time()
# Format prompt
prompt = BASELINE_QA_PROMPT.format(
dialogue=formatted_dialogue,
question=question
)
# Get answer from LLM
try:
# model_name = "qwen3-max"
@ -240,10 +240,10 @@ class BaselineQuestionAnsweringEvaluator:
logger.error(f"Error getting answer from LLM: {e}")
answer = "Error: Failed to get answer"
reasoning = str(e)
duration_ms = (time.time() - start) * 1000
return answer, reasoning, duration_ms
async def evaluate_questions(
self,
questions: list[dict],
@ -254,14 +254,14 @@ class BaselineQuestionAnsweringEvaluator:
) -> list[dict]:
"""Evaluate all questions for a session."""
results = []
for qa in questions:
# Get answer directly from LLM
answer, reasoning, duration_ms = await self.answer_question(
question=qa["question"],
formatted_dialogue=formatted_dialogue
)
# Evaluate response
evidence_text = "\n".join([e["memory_content"] for e in qa["evidence"]])
eval_result = await evaluation_for_question2(
@ -271,7 +271,7 @@ class BaselineQuestionAnsweringEvaluator:
answer,
formatted_dialogue
)
# Build result record
qa_result = {
**qa,
@ -284,13 +284,13 @@ class BaselineQuestionAnsweringEvaluator:
"question_answering_reasoning": eval_result.get("reasoning", "")
}
results.append(qa_result)
return results
class MetricsAggregator:
"""Aggregates evaluation metrics."""
@staticmethod
def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
"""Compute question answering metrics."""
@ -306,15 +306,15 @@ class MetricsAggregator:
"qa_valid_num": 0,
"qa_num": 0
}
correct = 0
hallucination = 0
omission = 0
valid = 0
for qa in qa_records:
result_type = qa.get("result_type", "")
if result_type in ["Correct", "Hallucination", "Omission"]:
valid += 1
if result_type == "Correct":
@ -323,7 +323,7 @@ class MetricsAggregator:
hallucination += 1
elif result_type == "Omission":
omission += 1
metrics = {
"correct_qa_ratio(all)": correct / total,
"hallucination_qa_ratio(all)": hallucination / total,
@ -331,7 +331,7 @@ class MetricsAggregator:
"qa_valid_num": valid,
"qa_num": total
}
if valid > 0:
metrics.update({
"correct_qa_ratio(valid)": correct / valid,
@ -344,25 +344,25 @@ class MetricsAggregator:
"hallucination_qa_ratio(valid)": 0,
"omission_qa_ratio(valid)": 0
})
return metrics
@staticmethod
def compute_time_metrics(eval_results_file: str) -> dict[str, float]:
"""Compute timing metrics from evaluation results."""
answer_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["sessions"]:
eval_results = session.get("evaluation_results", {})
for qa in eval_results.get("question_answering_records", []):
answer_duration += qa.get("answer_duration_ms", 0)
# Convert to minutes
return {
"answer_duration_time": answer_duration / 1000 / 60,
@ -374,13 +374,13 @@ class MetricsAggregator:
class HaluMemBaselineEvaluator:
"""Main evaluator orchestrating the baseline evaluation pipeline."""
def __init__(self, config: EvalConfig):
self.config = config
self.file_manager = FileManager(config.output_dir)
self.qa_evaluator = BaselineQuestionAnsweringEvaluator()
self.data_loader = DataLoader()
async def process_session(
self,
session: dict,
@ -395,16 +395,16 @@ class HaluMemBaselineEvaluator:
"session_id": session_id,
"memory_points": session["memory_points"]
}
# Skip generated QA sessions
if session.get("is_generated_qa_session", False):
session_data["is_generated_qa_session"] = True
return session_data
# Store dialogue
dialogue = session["dialogue"]
session_data["dialogue"] = dialogue
# Evaluate questions if present
if "questions" in session:
formatted_dialogue = self.data_loader.format_dialogue_for_eval(dialogue, user_name)
@ -415,25 +415,25 @@ class HaluMemBaselineEvaluator:
session_id=session_id,
formatted_dialogue=formatted_dialogue
)
session_data["evaluation_results"] = {
"question_answering_records": qa_results
}
return session_data
async def process_user(self, user_data: dict) -> dict:
"""Process all sessions for a user."""
user_name = self.data_loader.extract_user_name(user_data["persona_info"])
uuid = user_data["uuid"]
total_sessions = len(user_data["sessions"])
logger.info(f"Processing user: {user_name} ({total_sessions} sessions)")
# Semaphore for concurrency control within user sessions
semaphore = asyncio.Semaphore(self.config.max_concurrency)
completed_count = [0] # Use list to allow modification in nested async function
async def process_session_with_log(idx: int, session: dict):
async with semaphore:
session_data = await self.process_session(
@ -442,65 +442,65 @@ class HaluMemBaselineEvaluator:
user_name=user_name,
uuid=uuid
)
self.file_manager.save_session(user_name, idx, session_data)
# Update and log completion
completed_count[0] += 1
print(f"{user_name} complete {completed_count[0]}/{total_sessions}")
# Process all sessions in parallel
tasks = [
process_session_with_log(idx, session)
for idx, session in enumerate(user_data["sessions"])
]
await asyncio.gather(*tasks)
return {"uuid": uuid, "user_name": user_name, "status": "ok"}
async def run_evaluation(self):
"""Run the complete evaluation pipeline."""
start_time = time.time()
# Load user data
all_users = self.data_loader.load_jsonl(self.config.data_path)
users_to_process = all_users[:self.config.user_num]
print("\n" + "=" * 80)
print("HALUMEM BASELINE EVALUATION - DIRECT QA WITHOUT MEMORY SYSTEM")
print(f"Users: {len(users_to_process)} | Session Concurrency: {self.config.max_concurrency}")
print("=" * 80 + "\n")
# Process users sequentially (for loop)
for idx, user_data in enumerate(users_to_process, 1):
user_name = self.data_loader.extract_user_name(user_data["persona_info"])
# Check cache
if self.file_manager.user_has_cache(user_name):
print(f"⚡ [{idx}/{len(users_to_process)}] Skipping {user_name} (cached)")
continue
print(f"🔄 [{idx}/{len(users_to_process)}] Processing {user_name}...")
await self.process_user(user_data)
print(f"✅ [{idx}/{len(users_to_process)}] User {user_name} completed\n")
# Combine results
output_file = os.path.join(self.config.output_dir, "eval_results.jsonl")
self.file_manager.combine_results(output_file)
elapsed = time.time() - start_time
print(f"\n✅ Processing completed in {elapsed:.2f}s")
print(f"📁 Results: {output_file}\n")
# Aggregate metrics
await self.aggregate_and_report(output_file)
async def aggregate_and_report(self, results_file: str):
"""Aggregate results and generate final report."""
print("=" * 80)
print("AGGREGATING METRICS")
print("=" * 80 + "\n")
# Collect all QA records
qa_records = []
with open(results_file, "r", encoding="utf-8") as f:
@ -508,20 +508,20 @@ class HaluMemBaselineEvaluator:
if not line.strip():
continue
user_data = json.loads(line)
for session in user_data["sessions"]:
if session.get("is_generated_qa_session"):
continue
eval_results = session.get("evaluation_results", {})
qa_records.extend(
eval_results.get("question_answering_records", [])
)
# Compute metrics
qa_metrics = MetricsAggregator.compute_qa_metrics(qa_records)
time_metrics = MetricsAggregator.compute_time_metrics(results_file)
final_results = {
"overall_score": {
"question_answering": qa_metrics,
@ -529,23 +529,23 @@ class HaluMemBaselineEvaluator:
},
"question_answering_records": qa_records
}
# Save final report
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)
print(f"📊 Statistics saved to: {report_file}\n")
# Print summary
self._print_summary(qa_metrics, time_metrics)
def _print_summary(self, qa_metrics: dict, time_metrics: dict):
"""Print evaluation summary."""
print("=" * 80)
print("EVALUATION SUMMARY")
print("=" * 80 + "\n")
print("📊 Question Answering:")
print(f" Correct (all): {qa_metrics['correct_qa_ratio(all)']:.4f}")
print(f" Hallucination (all): {qa_metrics['hallucination_qa_ratio(all)']:.4f}")
@ -554,7 +554,7 @@ class HaluMemBaselineEvaluator:
print(f" Hallucination (valid): {qa_metrics['hallucination_qa_ratio(valid)']:.4f}")
print(f" Omission (valid): {qa_metrics['omission_qa_ratio(valid)']:.4f}")
print(f" Valid/Total: {qa_metrics['qa_valid_num']}/{qa_metrics['qa_num']}")
print(f"\n⏱️ Time Metrics:")
print(f" Answer Duration: {time_metrics['answer_duration_time']:.2f} min")
print(f" Total: {time_metrics['total_duration_time']:.2f} min")
@ -574,14 +574,14 @@ def main(
user_num=user_num,
max_concurrency=max_concurrency
)
evaluator = HaluMemBaselineEvaluator(config)
asyncio.run(evaluator.run_evaluation())
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="Evaluate Baseline (Direct QA) on HaluMem benchmark"
)
@ -603,9 +603,9 @@ if __name__ == "__main__":
default=2,
help="Maximum concurrent user processing (default: 2)"
)
args = parser.parse_args()
main(
data_path=args.data_path,
user_num=args.user_num,

View file

@ -35,8 +35,8 @@ from eval_tools import (
evaluation_for_update_memory,
)
from llms import llm_request
from reme_ai.core.enumeration import MemoryType
from reme_ai.core.schema import MemoryNode
from reme_ai.core_old.enumeration import MemoryType
from reme_ai.core_old.schema import MemoryNode
from reme_ai.reme import ReMe
# Template for formatting memories (from shared YAML config)
@ -685,7 +685,7 @@ async def main_async(
user_data_list = user_data_list[:total_users]
print(f"Processing {total_users} users with max concurrency {max_concurrency}...")
# Create semaphore to limit concurrency for Stage 1
semaphore_stage1 = asyncio.Semaphore(max_concurrency)
@ -694,11 +694,11 @@ async def main_async(
async with semaphore_stage1:
uuid = user_data['uuid']
tmp_file = os.path.join(tmp_dir, f"{uuid}.json")
if os.path.exists(tmp_file):
print(f"⚡ Skipping user {uuid} ({idx}/{total_users}) — cached result found.")
return {"uuid": uuid, "status": "cached", "path": tmp_file}
print(f"[{idx}/{total_users}] Processing user {uuid}...")
result = await process_user_stage1(user_data, top_k, save_path)
print(f"[{idx}/{total_users}] ✅ Finished {uuid} ({result['status']})")
@ -733,7 +733,7 @@ async def main_async(
# Load all users and process sequentially
user_data_list = list(enumerate(iter_jsonl(output_file_stage1), 1))
for idx, user_data in user_data_list:
uuid = user_data["uuid"]
tmp_file = os.path.join(tmp_dir2, f"{uuid}.json")

View file

@ -26,8 +26,8 @@ from typing import Any
from loguru import logger
from eval_tools import evaluation_for_question2
from reme_ai.core.enumeration import MemoryType
from reme_ai.core.schema import MemoryNode
from reme_ai.core_old.enumeration import MemoryType
from reme_ai.core_old.schema import MemoryNode
from reme_ai.reme import ReMe
@ -48,13 +48,13 @@ class EvalConfig:
class DataLoader:
"""Handles loading and parsing of HaluMem data."""
@staticmethod
def load_jsonl(file_path: str) -> list[dict]:
"""Load all entries from a JSONL file."""
with open(file_path, "r", encoding="utf-8") as f:
return [json.loads(line.strip()) for line in f if line.strip()]
@staticmethod
def extract_user_name(persona_info: str) -> str:
"""Extract user name from persona info string."""
@ -62,7 +62,7 @@ class DataLoader:
if not match:
raise ValueError(f"No name found in persona_info: {persona_info}")
return match.group(1).strip()
@staticmethod
def format_dialogue_messages(dialogue: list[dict]) -> list[dict]:
"""Format dialogue into ReMe message format."""
@ -78,7 +78,7 @@ class DataLoader:
}
for turn in dialogue
]
@staticmethod
def format_dialogue_for_eval(dialogue: list[dict], user_name: str = None) -> str:
"""Format dialogue into string for evaluation."""
@ -87,10 +87,10 @@ class DataLoader:
timestamp = datetime.strptime(
turn["timestamp"], "%b %d, %Y, %H:%M:%S"
).replace(tzinfo=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
# Use user_name if role is 'user' and user_name is provided
role = user_name if turn['role'] == 'user' and user_name else turn['role']
formatted_turns.append(
f"Role: {role}\n"
f"Content: {turn['content']}\n"
@ -101,29 +101,29 @@ class DataLoader:
class FileManager:
"""Manages file I/O operations."""
def __init__(self, base_dir: str):
self.base_dir = Path(base_dir)
self.tmp_dir = self.base_dir / "tmp"
self.tmp_dir.mkdir(parents=True, exist_ok=True)
def get_user_dir(self, user_name: str) -> Path:
"""Get the directory path for a user."""
user_dir = self.tmp_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 specific session."""
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 data to 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)
logger.info(f"✅ Saved session {session_id} to {file_path}")
def load_session(self, user_name: str, session_id: int) -> dict | None:
"""Load session data from file."""
file_path = self.get_session_file(user_name, session_id)
@ -131,38 +131,38 @@ class FileManager:
return None
with open(file_path, "r", encoding="utf-8") as f:
return json.load(f)
def user_has_cache(self, user_name: str) -> bool:
"""Check if user has cached results."""
user_dir = self.get_user_dir(user_name)
return any(f.name.startswith("session_") and f.suffix == ".json"
return any(f.name.startswith("session_") and f.suffix == ".json"
for f in user_dir.iterdir())
def combine_results(self, output_file: str):
"""Combine all user session files into a single JSONL file."""
with open(output_file, "w", encoding="utf-8") as f_out:
for user_dir in self.tmp_dir.iterdir():
if not user_dir.is_dir():
continue
session_files = sorted([
f for f in user_dir.iterdir()
f for f in user_dir.iterdir()
if f.name.startswith("session_") and f.suffix == ".json"
])
if not session_files:
continue
# Load first session to get user metadata
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": []
}
# Load all sessions
for session_file in session_files:
with open(session_file, "r", encoding="utf-8") as f_in:
@ -171,7 +171,7 @@ class FileManager:
session_data.pop("uuid", None)
session_data.pop("user_name", None)
user_data["sessions"].append(session_data)
f_out.write(json.dumps(user_data, ensure_ascii=False) + "\n")
@ -179,19 +179,19 @@ class FileManager:
class MemoryProcessor:
"""Handles ReMe memory operations."""
def __init__(self, reme: ReMe):
self.reme = reme
async def add_memories(
self,
user_id: str,
self,
user_id: str,
messages: list[dict],
batch_size: int = 20
) -> tuple[list[str], list[list[dict]], float]:
"""
Add memories in batches and return extracted memory contents.
Returns:
tuple: (extracted_memories, agent_messages, total_duration_ms)
"""
@ -202,19 +202,19 @@ class MemoryProcessor:
for i in range(0, len(messages), batch_size):
batch = messages[i:i + batch_size]
start = time.time()
memory_nodes, agent_messages, success = await self.reme.summary_v2(
messages=batch,
messages=batch,
user_id=user_id
)
duration_ms = (time.time() - start) * 1000
total_duration_ms += duration_ms
# Save agent messages for this batch
if agent_messages:
all_agent_messages.extend(agent_messages)
if memory_nodes:
for node in memory_nodes:
if isinstance(node, MemoryNode) and node.memory_type == MemoryType.HISTORY:
@ -230,23 +230,23 @@ class MemoryProcessor:
extracted_memories += ["[delete]" + n.format_memory() for n in added_memories if n.memory_id in deleted_memories]
extracted_memories += ["[add]" + n.format_memory() for n in added_memories if n.memory_id not in deleted_memories]
return extracted_memories, all_agent_messages, total_duration_ms
async def search_memory(
self,
query: str,
user_id: str,
self,
query: str,
user_id: str,
top_k: int = 20
) -> tuple[str, list, float]:
"""
Search memory and return response.
Returns:
tuple: (response, agent_messages, duration_ms)
"""
start = time.time()
response, agent_messages, success = await self.reme.retrieve_v2(
query=query,
user_id=user_id,
query=query,
user_id=user_id,
top_k=top_k
)
duration_ms = (time.time() - start) * 1000
@ -257,11 +257,11 @@ class MemoryProcessor:
class QuestionAnsweringEvaluator:
"""Evaluates question answering performance."""
def __init__(self, memory_processor: MemoryProcessor, top_k: int):
self.memory_processor = memory_processor
self.top_k = top_k
async def evaluate_questions(
self,
questions: list[dict],
@ -272,7 +272,7 @@ class QuestionAnsweringEvaluator:
) -> list[dict]:
"""Evaluate all questions for a session."""
results = []
for qa in questions:
# Search memory for answer
response, agent_messages, duration_ms = await self.memory_processor.search_memory(
@ -280,7 +280,7 @@ class QuestionAnsweringEvaluator:
user_id=user_name,
top_k=self.top_k
)
# Evaluate response
evidence_text = "\n".join([e["memory_content"] for e in qa["evidence"]])
eval_result = await evaluation_for_question2(
@ -290,7 +290,7 @@ class QuestionAnsweringEvaluator:
response,
formatted_dialogue
)
# Build result record
qa_result = {
**qa,
@ -303,13 +303,13 @@ class QuestionAnsweringEvaluator:
"question_answering_reasoning": eval_result.get("reasoning", "")
}
results.append(qa_result)
return results
class MetricsAggregator:
"""Aggregates evaluation metrics."""
@staticmethod
def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
"""Compute question answering metrics."""
@ -325,15 +325,15 @@ class MetricsAggregator:
"qa_valid_num": 0,
"qa_num": 0
}
correct = 0
hallucination = 0
omission = 0
valid = 0
for qa in qa_records:
result_type = qa.get("result_type", "")
if result_type in ["Correct", "Hallucination", "Omission"]:
valid += 1
if result_type == "Correct":
@ -342,7 +342,7 @@ class MetricsAggregator:
hallucination += 1
elif result_type == "Omission":
omission += 1
metrics = {
"correct_qa_ratio(all)": correct / total,
"hallucination_qa_ratio(all)": hallucination / total,
@ -350,7 +350,7 @@ class MetricsAggregator:
"qa_valid_num": valid,
"qa_num": total
}
if valid > 0:
metrics.update({
"correct_qa_ratio(valid)": correct / valid,
@ -363,28 +363,28 @@ class MetricsAggregator:
"hallucination_qa_ratio(valid)": 0,
"omission_qa_ratio(valid)": 0
})
return metrics
@staticmethod
def compute_time_metrics(eval_results_file: str) -> dict[str, float]:
"""Compute timing 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["sessions"]:
add_duration += session.get("add_dialogue_duration_ms", 0)
eval_results = session.get("evaluation_results", {})
for qa in eval_results.get("question_answering_records", []):
search_duration += qa.get("search_duration_ms", 0)
# Convert to minutes
return {
"add_dialogue_duration_time": add_duration / 1000 / 60,
@ -397,18 +397,18 @@ class MetricsAggregator:
class HaluMemEvaluator:
"""Main evaluator orchestrating the entire pipeline."""
def __init__(self, config: EvalConfig):
self.config = config
self.reme = ReMe()
self.file_manager = FileManager(config.output_dir)
self.memory_processor = MemoryProcessor(self.reme)
self.qa_evaluator = QuestionAnsweringEvaluator(
self.memory_processor,
self.memory_processor,
config.top_k
)
self.data_loader = DataLoader()
async def process_session(
self,
session: dict,
@ -423,29 +423,29 @@ class HaluMemEvaluator:
"session_id": session_id,
"memory_points": session["memory_points"]
}
# Skip generated QA sessions
if session.get("is_generated_qa_session", False):
session_data["is_generated_qa_session"] = True
return session_data
# Format and add dialogue to memory
dialogue = session["dialogue"]
formatted_messages = self.data_loader.format_dialogue_messages(dialogue)
extracted_memories, agent_messages, duration_ms = await self.memory_processor.add_memories(
user_id=user_name,
messages=formatted_messages,
batch_size=self.config.batch_size
)
session_data.update({
"dialogue": dialogue,
"extracted_memories": extracted_memories,
"summary_messages": [m.model_dump() for m in agent_messages],
"add_dialogue_duration_ms": duration_ms
})
# Evaluate questions if present
if "questions" in session:
formatted_dialogue = self.data_loader.format_dialogue_for_eval(dialogue, user_name)
@ -456,90 +456,90 @@ class HaluMemEvaluator:
session_id=session_id,
formatted_dialogue=formatted_dialogue
)
session_data["evaluation_results"] = {
"question_answering_records": qa_results
}
return session_data
async def process_user(self, user_data: dict) -> dict:
"""Process all sessions for a user."""
user_name = self.data_loader.extract_user_name(user_data["persona_info"])
uuid = user_data["uuid"]
logger.info(f"Processing user: {user_name}")
for idx, session in enumerate(user_data["sessions"]):
logger.info(f" Session {idx + 1}/{len(user_data['sessions'])}")
session_data = await self.process_session(
session=session,
session_id=idx,
user_name=user_name,
uuid=uuid
)
self.file_manager.save_session(user_name, idx, session_data)
return {"uuid": uuid, "user_name": user_name, "status": "ok"}
async def run_evaluation(self):
"""Run the complete evaluation pipeline."""
start_time = time.time()
# Clear existing data
await self.reme.vector_store.delete_all()
# Load user data
all_users = self.data_loader.load_jsonl(self.config.data_path)
users_to_process = all_users[:self.config.user_num]
print("\n" + "=" * 80)
print("HALUMEM EVALUATION - QUESTION ANSWERING")
print(f"Users: {len(users_to_process)} | Concurrency: {self.config.max_concurrency}")
print("=" * 80 + "\n")
# Process users with concurrency control
semaphore = asyncio.Semaphore(self.config.max_concurrency)
async def process_with_cache_check(idx: int, user_data: dict):
async with semaphore:
user_name = self.data_loader.extract_user_name(user_data["persona_info"])
# Check cache
if self.file_manager.user_has_cache(user_name):
print(f"⚡ [{idx}/{len(users_to_process)}] Skipping {user_name} (cached)")
return {"user_name": user_name, "status": "cached"}
print(f"🔄 [{idx}/{len(users_to_process)}] Processing {user_name}...")
result = await self.process_user(user_data)
print(f"✅ [{idx}/{len(users_to_process)}] Completed {user_name}")
return result
tasks = [
process_with_cache_check(idx, user)
process_with_cache_check(idx, user)
for idx, user in enumerate(users_to_process, 1)
]
await asyncio.gather(*tasks)
# Combine results
output_file = os.path.join(self.config.output_dir, "eval_results.jsonl")
self.file_manager.combine_results(output_file)
elapsed = time.time() - start_time
print(f"\n✅ Processing completed in {elapsed:.2f}s")
print(f"📁 Results: {output_file}\n")
# Aggregate metrics
await self.aggregate_and_report(output_file)
async def aggregate_and_report(self, results_file: str):
"""Aggregate results and generate final report."""
print("=" * 80)
print("AGGREGATING METRICS")
print("=" * 80 + "\n")
# Collect all QA records
qa_records = []
with open(results_file, "r", encoding="utf-8") as f:
@ -547,20 +547,20 @@ class HaluMemEvaluator:
if not line.strip():
continue
user_data = json.loads(line)
for session in user_data["sessions"]:
if session.get("is_generated_qa_session"):
continue
eval_results = session.get("evaluation_results", {})
qa_records.extend(
eval_results.get("question_answering_records", [])
)
# Compute metrics
qa_metrics = MetricsAggregator.compute_qa_metrics(qa_records)
time_metrics = MetricsAggregator.compute_time_metrics(results_file)
final_results = {
"overall_score": {
"question_answering": qa_metrics,
@ -568,23 +568,23 @@ class HaluMemEvaluator:
},
"question_answering_records": qa_records
}
# Save final report
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)
print(f"📊 Statistics saved to: {report_file}\n")
# Print summary
self._print_summary(qa_metrics, time_metrics)
def _print_summary(self, qa_metrics: dict, time_metrics: dict):
"""Print evaluation summary."""
print("=" * 80)
print("EVALUATION SUMMARY")
print("=" * 80 + "\n")
print("📊 Question Answering:")
print(f" Correct (all): {qa_metrics['correct_qa_ratio(all)']:.4f}")
print(f" Hallucination (all): {qa_metrics['hallucination_qa_ratio(all)']:.4f}")
@ -593,7 +593,7 @@ class HaluMemEvaluator:
print(f" Hallucination (valid): {qa_metrics['hallucination_qa_ratio(valid)']:.4f}")
print(f" Omission (valid): {qa_metrics['omission_qa_ratio(valid)']:.4f}")
print(f" Valid/Total: {qa_metrics['qa_valid_num']}/{qa_metrics['qa_num']}")
print(f"\n⏱️ Time 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")
@ -616,14 +616,14 @@ def main(
user_num=user_num,
max_concurrency=max_concurrency
)
evaluator = HaluMemEvaluator(config)
asyncio.run(evaluator.run_evaluation())
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="Evaluate ReMe on HaluMem benchmark (Question Answering)"
)
@ -651,9 +651,9 @@ if __name__ == "__main__":
default=2,
help="Maximum concurrent user processing (default: 2)"
)
args = parser.parse_args()
main(
data_path=args.data_path,
top_k=args.top_k,

View file

@ -27,8 +27,8 @@ from typing import Any
from loguru import logger
from eval_tools import evaluation_for_question2
from reme_ai.core.enumeration import MemoryType
from reme_ai.core.schema import MemoryNode
from reme_ai.core_old.enumeration import MemoryType
from reme_ai.core_old.schema import MemoryNode
from reme_ai.reme import ReMe
@ -49,13 +49,13 @@ class EvalConfig:
class DataLoader:
"""Handles loading and parsing of HaluMem data."""
@staticmethod
def load_jsonl(file_path: str) -> list[dict]:
"""Load all entries from a JSONL file."""
with open(file_path, "r", encoding="utf-8") as f:
return [json.loads(line.strip()) for line in f if line.strip()]
@staticmethod
def extract_user_name(persona_info: str) -> str:
"""Extract user name from persona info string."""
@ -63,7 +63,7 @@ class DataLoader:
if not match:
raise ValueError(f"No name found in persona_info: {persona_info}")
return match.group(1).strip()
@staticmethod
def format_dialogue_messages(dialogue: list[dict]) -> list[dict]:
"""Format dialogue into ReMe message format with conversation_time (user messages only)."""
@ -80,7 +80,7 @@ class DataLoader:
for turn in dialogue
if turn["role"] == "user" # Only include user messages
]
@staticmethod
def format_dialogue_for_eval(dialogue: list[dict], user_name: str = None) -> str:
"""Format dialogue into string for evaluation."""
@ -89,10 +89,10 @@ class DataLoader:
timestamp = datetime.strptime(
turn["timestamp"], "%b %d, %Y, %H:%M:%S"
).replace(tzinfo=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
# Use user_name if role is 'user' and user_name is provided
role = user_name if turn['role'] == 'user' and user_name else turn['role']
formatted_turns.append(
f"Role: {role}\n"
f"Content: {turn['content']}\n"
@ -103,29 +103,29 @@ class DataLoader:
class FileManager:
"""Manages file I/O operations."""
def __init__(self, base_dir: str):
self.base_dir = Path(base_dir)
self.tmp_dir = self.base_dir / "tmp"
self.tmp_dir.mkdir(parents=True, exist_ok=True)
def get_user_dir(self, user_name: str) -> Path:
"""Get the directory path for a user."""
user_dir = self.tmp_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 specific session."""
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 data to 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)
logger.info(f"✅ Saved session {session_id} to {file_path}")
def load_session(self, user_name: str, session_id: int) -> dict | None:
"""Load session data from file."""
file_path = self.get_session_file(user_name, session_id)
@ -133,38 +133,38 @@ class FileManager:
return None
with open(file_path, "r", encoding="utf-8") as f:
return json.load(f)
def user_has_cache(self, user_name: str) -> bool:
"""Check if user has cached results."""
user_dir = self.get_user_dir(user_name)
return any(f.name.startswith("session_") and f.suffix == ".json"
return any(f.name.startswith("session_") and f.suffix == ".json"
for f in user_dir.iterdir())
def combine_results(self, output_file: str):
"""Combine all user session files into a single JSONL file."""
with open(output_file, "w", encoding="utf-8") as f_out:
for user_dir in self.tmp_dir.iterdir():
if not user_dir.is_dir():
continue
session_files = sorted([
f for f in user_dir.iterdir()
f for f in user_dir.iterdir()
if f.name.startswith("session_") and f.suffix == ".json"
])
if not session_files:
continue
# Load first session to get user metadata
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": []
}
# Load all sessions
for session_file in session_files:
with open(session_file, "r", encoding="utf-8") as f_in:
@ -173,7 +173,7 @@ class FileManager:
session_data.pop("uuid", None)
session_data.pop("user_name", None)
user_data["sessions"].append(session_data)
f_out.write(json.dumps(user_data, ensure_ascii=False) + "\n")
@ -181,19 +181,19 @@ class FileManager:
class MemoryProcessor:
"""Handles ReMe V3 memory operations."""
def __init__(self, reme: ReMe):
self.reme = reme
async def add_memories(
self,
user_id: str,
self,
user_id: str,
messages: list[dict],
batch_size: int = 10000
) -> tuple[list[str], list[list[dict]], float]:
"""
Add memories in batches using ReMe V3 and return extracted memory contents.
Returns:
tuple: (extracted_memories, agent_messages, total_duration_ms)
"""
@ -201,24 +201,24 @@ class MemoryProcessor:
deleted_memories: list[str] = []
all_agent_messages: list = []
total_duration_ms = 0
for i in range(0, len(messages), batch_size):
batch = messages[i:i + batch_size]
start = time.time()
# Use summary_v3 instead of summary_v2
memory_nodes, agent_messages, success = await self.reme.summary_v3(
messages=batch,
messages=batch,
user_id=user_id
)
duration_ms = (time.time() - start) * 1000
total_duration_ms += duration_ms
# Save agent messages for this batch
if agent_messages:
all_agent_messages.extend(agent_messages)
if memory_nodes:
for node in memory_nodes:
if isinstance(node, MemoryNode) and node.memory_type == MemoryType.HISTORY:
@ -234,28 +234,28 @@ class MemoryProcessor:
extracted_memories += ["[delete]" + n.format_memory() for n in added_memories if n.memory_id in deleted_memories]
extracted_memories += ["[add]" + n.format_memory() for n in added_memories if n.memory_id not in deleted_memories]
return extracted_memories, all_agent_messages, total_duration_ms
async def search_memory(
self,
query: str,
user_id: str,
self,
query: str,
user_id: str,
top_k: int = 20
) -> tuple[str, list, float]:
"""
Search memory using ReMe V3 and return response.
Returns:
tuple: (response, agent_messages, duration_ms)
"""
start = time.time()
# Use retrieve_v3 instead of retrieve_v2
response, agent_messages, success = await self.reme.retrieve_v3(
query=query,
user_id=user_id,
query=query,
user_id=user_id,
top_k=top_k
)
duration_ms = (time.time() - start) * 1000
return response, agent_messages, duration_ms
@ -264,11 +264,11 @@ class MemoryProcessor:
class QuestionAnsweringEvaluator:
"""Evaluates question answering performance."""
def __init__(self, memory_processor: MemoryProcessor, top_k: int):
self.memory_processor = memory_processor
self.top_k = top_k
async def evaluate_questions(
self,
questions: list[dict],
@ -279,7 +279,7 @@ class QuestionAnsweringEvaluator:
) -> list[dict]:
"""Evaluate all questions for a session."""
results = []
for qa in questions:
# Search memory for answer using V3
response, agent_messages, duration_ms = await self.memory_processor.search_memory(
@ -287,7 +287,7 @@ class QuestionAnsweringEvaluator:
user_id=user_name,
top_k=self.top_k
)
# Evaluate response
evidence_text = "\n".join([e["memory_content"] for e in qa["evidence"]])
eval_result = await evaluation_for_question2(
@ -297,7 +297,7 @@ class QuestionAnsweringEvaluator:
response,
formatted_dialogue
)
# Build result record
qa_result = {
**qa,
@ -310,13 +310,13 @@ class QuestionAnsweringEvaluator:
"question_answering_reasoning": eval_result.get("reasoning", "")
}
results.append(qa_result)
return results
class MetricsAggregator:
"""Aggregates evaluation metrics."""
@staticmethod
def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
"""Compute question answering metrics."""
@ -332,15 +332,15 @@ class MetricsAggregator:
"qa_valid_num": 0,
"qa_num": 0
}
correct = 0
hallucination = 0
omission = 0
valid = 0
for qa in qa_records:
result_type = qa.get("result_type", "")
if result_type in ["Correct", "Hallucination", "Omission"]:
valid += 1
if result_type == "Correct":
@ -349,7 +349,7 @@ class MetricsAggregator:
hallucination += 1
elif result_type == "Omission":
omission += 1
metrics = {
"correct_qa_ratio(all)": correct / total,
"hallucination_qa_ratio(all)": hallucination / total,
@ -357,7 +357,7 @@ class MetricsAggregator:
"qa_valid_num": valid,
"qa_num": total
}
if valid > 0:
metrics.update({
"correct_qa_ratio(valid)": correct / valid,
@ -370,28 +370,28 @@ class MetricsAggregator:
"hallucination_qa_ratio(valid)": 0,
"omission_qa_ratio(valid)": 0
})
return metrics
@staticmethod
def compute_time_metrics(eval_results_file: str) -> dict[str, float]:
"""Compute timing 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["sessions"]:
add_duration += session.get("add_dialogue_duration_ms", 0)
eval_results = session.get("evaluation_results", {})
for qa in eval_results.get("question_answering_records", []):
search_duration += qa.get("search_duration_ms", 0)
# Convert to minutes
return {
"add_dialogue_duration_time": add_duration / 1000 / 60,
@ -404,18 +404,18 @@ class MetricsAggregator:
class HaluMemEvaluatorV3:
"""Main evaluator orchestrating the entire ReMe V3 pipeline."""
def __init__(self, config: EvalConfig):
self.config = config
self.reme = ReMe()
self.file_manager = FileManager(config.output_dir)
self.memory_processor = MemoryProcessor(self.reme)
self.qa_evaluator = QuestionAnsweringEvaluator(
self.memory_processor,
self.memory_processor,
config.top_k
)
self.data_loader = DataLoader()
async def process_session(
self,
session: dict,
@ -430,29 +430,29 @@ class HaluMemEvaluatorV3:
"session_id": session_id,
"memory_points": session["memory_points"]
}
# Skip generated QA sessions
if session.get("is_generated_qa_session", False):
session_data["is_generated_qa_session"] = True
return session_data
# Format and add dialogue to memory using V3
dialogue = session["dialogue"]
formatted_messages = self.data_loader.format_dialogue_messages(dialogue)
extracted_memories, agent_messages, duration_ms = await self.memory_processor.add_memories(
user_id=user_name,
messages=formatted_messages,
batch_size=self.config.batch_size
)
session_data.update({
"dialogue": dialogue,
"extracted_memories": extracted_memories,
"summary_messages": [m.model_dump() for m in agent_messages],
"add_dialogue_duration_ms": duration_ms
})
# Evaluate questions if present
if "questions" in session:
formatted_dialogue = self.data_loader.format_dialogue_for_eval(dialogue, user_name)
@ -463,97 +463,97 @@ class HaluMemEvaluatorV3:
session_id=session_id,
formatted_dialogue=formatted_dialogue
)
session_data["evaluation_results"] = {
"question_answering_records": qa_results
}
return session_data
async def process_user(self, user_data: dict) -> dict:
"""Process all sessions for a user."""
user_name = self.data_loader.extract_user_name(user_data["persona_info"])
uuid = user_data["uuid"]
logger.info(f"Processing user: {user_name}")
for idx, session in enumerate(user_data["sessions"]):
logger.info(f" Session {idx + 1}/{len(user_data['sessions'])}")
session_data = await self.process_session(
session=session,
session_id=idx,
user_name=user_name,
uuid=uuid
)
self.file_manager.save_session(user_name, idx, session_data)
return {"uuid": uuid, "user_name": user_name, "status": "ok"}
async def run_evaluation(self):
"""Run the complete evaluation pipeline using ReMe V3."""
start_time = time.time()
# Clear existing data
await self.reme.vector_store.delete_all()
# Clear meta_memory directory
meta_memory_path = Path(f"meta_memory/{self.reme.vector_store.collection_name}")
if meta_memory_path.exists():
shutil.rmtree(meta_memory_path)
logger.info(f"Cleared meta_memory directory: {meta_memory_path}")
meta_memory_path.mkdir(parents=True, exist_ok=True)
# Load user data
all_users = self.data_loader.load_jsonl(self.config.data_path)
users_to_process = all_users[:self.config.user_num]
print("\n" + "=" * 80)
print("HALUMEM EVALUATION - REME V3 - QUESTION ANSWERING")
print(f"Users: {len(users_to_process)} | Concurrency: {self.config.max_concurrency}")
print("=" * 80 + "\n")
# Process users with concurrency control
semaphore = asyncio.Semaphore(self.config.max_concurrency)
async def process_with_cache_check(idx: int, user_data: dict):
async with semaphore:
user_name = self.data_loader.extract_user_name(user_data["persona_info"])
# Check cache
if self.file_manager.user_has_cache(user_name):
print(f"⚡ [{idx}/{len(users_to_process)}] Skipping {user_name} (cached)")
return {"user_name": user_name, "status": "cached"}
print(f"🔄 [{idx}/{len(users_to_process)}] Processing {user_name}...")
result = await self.process_user(user_data)
print(f"✅ [{idx}/{len(users_to_process)}] Completed {user_name}")
return result
tasks = [
process_with_cache_check(idx, user)
process_with_cache_check(idx, user)
for idx, user in enumerate(users_to_process, 1)
]
await asyncio.gather(*tasks)
# Combine results
output_file = os.path.join(self.config.output_dir, "eval_results.jsonl")
self.file_manager.combine_results(output_file)
elapsed = time.time() - start_time
print(f"\n✅ Processing completed in {elapsed:.2f}s")
print(f"📁 Results: {output_file}\n")
# Aggregate metrics
await self.aggregate_and_report(output_file)
async def aggregate_and_report(self, results_file: str):
"""Aggregate results and generate final report."""
print("=" * 80)
print("AGGREGATING METRICS")
print("=" * 80 + "\n")
# Collect all QA records
qa_records = []
with open(results_file, "r", encoding="utf-8") as f:
@ -561,20 +561,20 @@ class HaluMemEvaluatorV3:
if not line.strip():
continue
user_data = json.loads(line)
for session in user_data["sessions"]:
if session.get("is_generated_qa_session"):
continue
eval_results = session.get("evaluation_results", {})
qa_records.extend(
eval_results.get("question_answering_records", [])
)
# Compute metrics
qa_metrics = MetricsAggregator.compute_qa_metrics(qa_records)
time_metrics = MetricsAggregator.compute_time_metrics(results_file)
final_results = {
"overall_score": {
"question_answering": qa_metrics,
@ -582,23 +582,23 @@ class HaluMemEvaluatorV3:
},
"question_answering_records": qa_records
}
# Save final report
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)
print(f"📊 Statistics saved to: {report_file}\n")
# Print summary
self._print_summary(qa_metrics, time_metrics)
def _print_summary(self, qa_metrics: dict, time_metrics: dict):
"""Print evaluation summary."""
print("=" * 80)
print("EVALUATION SUMMARY - REME V3")
print("=" * 80 + "\n")
print("📊 Question Answering:")
print(f" Correct (all): {qa_metrics['correct_qa_ratio(all)']:.4f}")
print(f" Hallucination (all): {qa_metrics['hallucination_qa_ratio(all)']:.4f}")
@ -607,7 +607,7 @@ class HaluMemEvaluatorV3:
print(f" Hallucination (valid): {qa_metrics['hallucination_qa_ratio(valid)']:.4f}")
print(f" Omission (valid): {qa_metrics['omission_qa_ratio(valid)']:.4f}")
print(f" Valid/Total: {qa_metrics['qa_valid_num']}/{qa_metrics['qa_num']}")
print(f"\n⏱️ Time 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")
@ -630,14 +630,14 @@ def main(
user_num=user_num,
max_concurrency=max_concurrency
)
evaluator = HaluMemEvaluatorV3(config)
asyncio.run(evaluator.run_evaluation())
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="Evaluate ReMe V3 on HaluMem benchmark (Question Answering)"
)
@ -665,9 +665,9 @@ if __name__ == "__main__":
default=2,
help="Maximum concurrent user processing (default: 2)"
)
args = parser.parse_args()
main(
data_path=args.data_path,
top_k=args.top_k,

View file

@ -27,8 +27,8 @@ from typing import Any
from loguru import logger
from eval_tools import evaluation_for_question2, answer_question_with_memories
from reme_ai.core.enumeration import MemoryType
from reme_ai.core.schema import MemoryNode
from reme_ai.core_old.enumeration import MemoryType
from reme_ai.core_old.schema import MemoryNode
from reme_ai.reme import ReMe
@ -49,13 +49,13 @@ class EvalConfig:
class DataLoader:
"""Handles loading and parsing of HaluMem data."""
@staticmethod
def load_jsonl(file_path: str) -> list[dict]:
"""Load all entries from a JSONL file."""
with open(file_path, "r", encoding="utf-8") as f:
return [json.loads(line.strip()) for line in f if line.strip()]
@staticmethod
def extract_user_name(persona_info: str) -> str:
"""Extract user name from persona info string."""
@ -63,7 +63,7 @@ class DataLoader:
if not match:
raise ValueError(f"No name found in persona_info: {persona_info}")
return match.group(1).strip()
@staticmethod
def format_dialogue_messages(dialogue: list[dict]) -> list[dict]:
"""Format dialogue into ReMe message format with conversation_time (user messages only)."""
@ -80,7 +80,7 @@ class DataLoader:
for turn in dialogue
if turn["role"] == "user" # Only include user messages
]
@staticmethod
def format_dialogue_for_eval(dialogue: list[dict], user_name: str = None) -> str:
"""Format dialogue into string for evaluation."""
@ -89,10 +89,10 @@ class DataLoader:
timestamp = datetime.strptime(
turn["timestamp"], "%b %d, %Y, %H:%M:%S"
).replace(tzinfo=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
# Use user_name if role is 'user' and user_name is provided
role = user_name if turn['role'] == 'user' and user_name else turn['role']
formatted_turns.append(
f"Role: {role}\n"
f"Content: {turn['content']}\n"
@ -103,29 +103,29 @@ class DataLoader:
class FileManager:
"""Manages file I/O operations."""
def __init__(self, base_dir: str):
self.base_dir = Path(base_dir)
self.tmp_dir = self.base_dir / "tmp"
self.tmp_dir.mkdir(parents=True, exist_ok=True)
def get_user_dir(self, user_name: str) -> Path:
"""Get the directory path for a user."""
user_dir = self.tmp_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 specific session."""
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 data to 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)
logger.info(f"✅ Saved session {session_id} to {file_path}")
def load_session(self, user_name: str, session_id: int) -> dict | None:
"""Load session data from file."""
file_path = self.get_session_file(user_name, session_id)
@ -133,38 +133,38 @@ class FileManager:
return None
with open(file_path, "r", encoding="utf-8") as f:
return json.load(f)
def user_has_cache(self, user_name: str) -> bool:
"""Check if user has cached results."""
user_dir = self.get_user_dir(user_name)
return any(f.name.startswith("session_") and f.suffix == ".json"
return any(f.name.startswith("session_") and f.suffix == ".json"
for f in user_dir.iterdir())
def combine_results(self, output_file: str):
"""Combine all user session files into a single JSONL file."""
with open(output_file, "w", encoding="utf-8") as f_out:
for user_dir in self.tmp_dir.iterdir():
if not user_dir.is_dir():
continue
session_files = sorted([
f for f in user_dir.iterdir()
f for f in user_dir.iterdir()
if f.name.startswith("session_") and f.suffix == ".json"
])
if not session_files:
continue
# Load first session to get user metadata
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": []
}
# Load all sessions
for session_file in session_files:
with open(session_file, "r", encoding="utf-8") as f_in:
@ -173,7 +173,7 @@ class FileManager:
session_data.pop("uuid", None)
session_data.pop("user_name", None)
user_data["sessions"].append(session_data)
f_out.write(json.dumps(user_data, ensure_ascii=False) + "\n")
@ -181,19 +181,19 @@ class FileManager:
class MemoryProcessor:
"""Handles ReMe memory operations."""
def __init__(self, reme: ReMe):
self.reme = reme
async def add_memories(
self,
user_id: str,
self,
user_id: str,
messages: list[dict],
batch_size: int = 10000
) -> tuple[list[str], list[list[dict]], float]:
"""
Add memories in batches using ReMe and return extracted memory contents.
Returns:
tuple: (extracted_memories, agent_messages, total_duration_ms)
"""
@ -201,23 +201,23 @@ class MemoryProcessor:
deleted_memories: list[str] = []
all_agent_messages: list = []
total_duration_ms = 0
for i in range(0, len(messages), batch_size):
batch = messages[i:i + batch_size]
start = time.time()
memory_nodes, agent_messages, success = await self.reme.summary_v4(
messages=batch,
messages=batch,
user_id=user_id
)
duration_ms = (time.time() - start) * 1000
total_duration_ms += duration_ms
# Save agent messages for this batch
if agent_messages:
all_agent_messages.extend(agent_messages)
if memory_nodes:
for node in memory_nodes:
if isinstance(node, MemoryNode) and node.memory_type == MemoryType.HISTORY:
@ -233,39 +233,39 @@ class MemoryProcessor:
extracted_memories += ["[delete]" + n.format_memory() for n in added_memories if n.memory_id in deleted_memories]
extracted_memories += ["[add]" + n.format_memory() for n in added_memories if n.memory_id not in deleted_memories]
return extracted_memories, all_agent_messages, total_duration_ms
async def search_memory(
self,
query: str,
user_id: str,
self,
query: str,
user_id: str,
top_k: int = 20
) -> tuple[dict, list, float]:
"""
Search memory using ReMe and return structured answer with reasoning.
Returns:
tuple: (answer_dict, agent_messages, duration_ms)
answer_dict contains: {"reasoning": str, "answer": str, "memories": str}
"""
start = time.time()
# Retrieve memories from ReMe
memories_response, agent_messages, success = await self.reme.retrieve_v4(
query=query,
user_id=user_id,
query=query,
user_id=user_id,
top_k=top_k
)
# Use LLM to generate structured answer from memories
answer_result = await answer_question_with_memories(
question=query,
memories=memories_response,
user_id=user_id
)
# Add original memories to the result
answer_result["memories"] = memories_response
duration_ms = (time.time() - start) * 1000
return answer_result, agent_messages, duration_ms
@ -274,11 +274,11 @@ class MemoryProcessor:
class QuestionAnsweringEvaluator:
"""Evaluates question answering performance."""
def __init__(self, memory_processor: MemoryProcessor, top_k: int):
self.memory_processor = memory_processor
self.top_k = top_k
async def evaluate_questions(
self,
questions: list[dict],
@ -289,19 +289,19 @@ class QuestionAnsweringEvaluator:
) -> list[dict]:
"""Evaluate all questions for a session."""
results = []
for qa in questions:
answer_dict, agent_messages, duration_ms = await self.memory_processor.search_memory(
query=qa["question"],
user_id=user_name,
top_k=self.top_k
)
# Extract answer and reasoning from the structured response
system_answer = answer_dict.get("answer", "")
system_reasoning = answer_dict.get("reasoning", "")
retrieved_memories = answer_dict.get("memories", "")
# Evaluate response
evidence_text = "\n".join([e["memory_content"] for e in qa["evidence"]])
eval_result = await evaluation_for_question2(
@ -311,7 +311,7 @@ class QuestionAnsweringEvaluator:
system_answer,
formatted_dialogue
)
# Build result record
qa_result = {
**qa,
@ -326,13 +326,13 @@ class QuestionAnsweringEvaluator:
"question_answering_reasoning": eval_result.get("reasoning", "")
}
results.append(qa_result)
return results
class MetricsAggregator:
"""Aggregates evaluation metrics."""
@staticmethod
def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
"""Compute question answering metrics."""
@ -348,15 +348,15 @@ class MetricsAggregator:
"qa_valid_num": 0,
"qa_num": 0
}
correct = 0
hallucination = 0
omission = 0
valid = 0
for qa in qa_records:
result_type = qa.get("result_type", "")
if result_type in ["Correct", "Hallucination", "Omission"]:
valid += 1
if result_type == "Correct":
@ -365,7 +365,7 @@ class MetricsAggregator:
hallucination += 1
elif result_type == "Omission":
omission += 1
metrics = {
"correct_qa_ratio(all)": correct / total,
"hallucination_qa_ratio(all)": hallucination / total,
@ -373,7 +373,7 @@ class MetricsAggregator:
"qa_valid_num": valid,
"qa_num": total
}
if valid > 0:
metrics.update({
"correct_qa_ratio(valid)": correct / valid,
@ -386,28 +386,28 @@ class MetricsAggregator:
"hallucination_qa_ratio(valid)": 0,
"omission_qa_ratio(valid)": 0
})
return metrics
@staticmethod
def compute_time_metrics(eval_results_file: str) -> dict[str, float]:
"""Compute timing 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["sessions"]:
add_duration += session.get("add_dialogue_duration_ms", 0)
eval_results = session.get("evaluation_results", {})
for qa in eval_results.get("question_answering_records", []):
search_duration += qa.get("search_duration_ms", 0)
# Convert to minutes
return {
"add_dialogue_duration_time": add_duration / 1000 / 60,
@ -426,11 +426,11 @@ class HaluMemEvaluatorV4:
self.file_manager = FileManager(config.output_dir)
self.memory_processor = MemoryProcessor(self.reme)
self.qa_evaluator = QuestionAnsweringEvaluator(
self.memory_processor,
self.memory_processor,
config.top_k
)
self.data_loader = DataLoader()
async def process_session(
self,
session: dict,
@ -445,7 +445,7 @@ class HaluMemEvaluatorV4:
"session_id": session_id,
"memory_points": session["memory_points"]
}
# Skip generated QA sessions
if session.get("is_generated_qa_session", False):
session_data["is_generated_qa_session"] = True
@ -453,20 +453,20 @@ class HaluMemEvaluatorV4:
dialogue = session["dialogue"]
formatted_messages = self.data_loader.format_dialogue_messages(dialogue)
extracted_memories, agent_messages, duration_ms = await self.memory_processor.add_memories(
user_id=user_name,
messages=formatted_messages,
batch_size=self.config.batch_size
)
session_data.update({
"dialogue": dialogue,
"extracted_memories": extracted_memories,
"summary_messages": [m.model_dump() for m in agent_messages],
"add_dialogue_duration_ms": duration_ms
})
# Evaluate questions if present
if "questions" in session:
formatted_dialogue = self.data_loader.format_dialogue_for_eval(dialogue, user_name)
@ -477,97 +477,97 @@ class HaluMemEvaluatorV4:
session_id=session_id,
formatted_dialogue=formatted_dialogue
)
session_data["evaluation_results"] = {
"question_answering_records": qa_results
}
return session_data
async def process_user(self, user_data: dict) -> dict:
"""Process all sessions for a user."""
user_name = self.data_loader.extract_user_name(user_data["persona_info"])
uuid = user_data["uuid"]
logger.info(f"Processing user: {user_name}")
for idx, session in enumerate(user_data["sessions"]):
logger.info(f" Session {idx + 1}/{len(user_data['sessions'])}")
session_data = await self.process_session(
session=session,
session_id=idx,
user_name=user_name,
uuid=uuid
)
self.file_manager.save_session(user_name, idx, session_data)
return {"uuid": uuid, "user_name": user_name, "status": "ok"}
async def run_evaluation(self):
"""Run the complete evaluation pipeline using ReMe."""
start_time = time.time()
# Clear existing data
await self.reme.vector_store.delete_all()
# Clear meta_memory directory
meta_memory_path = Path(f"meta_memory/{self.reme.vector_store.collection_name}")
if meta_memory_path.exists():
shutil.rmtree(meta_memory_path)
logger.info(f"Cleared meta_memory directory: {meta_memory_path}")
meta_memory_path.mkdir(parents=True, exist_ok=True)
# Load user data
all_users = self.data_loader.load_jsonl(self.config.data_path)
users_to_process = all_users[:self.config.user_num]
print("\n" + "=" * 80)
print("HALUMEM EVALUATION - REME - QUESTION ANSWERING")
print(f"Users: {len(users_to_process)} | Concurrency: {self.config.max_concurrency}")
print("=" * 80 + "\n")
# Process users with concurrency control
semaphore = asyncio.Semaphore(self.config.max_concurrency)
async def process_with_cache_check(idx: int, user_data: dict):
async with semaphore:
user_name = self.data_loader.extract_user_name(user_data["persona_info"])
# Check cache
if self.file_manager.user_has_cache(user_name):
print(f"⚡ [{idx}/{len(users_to_process)}] Skipping {user_name} (cached)")
return {"user_name": user_name, "status": "cached"}
print(f"🔄 [{idx}/{len(users_to_process)}] Processing {user_name}...")
result = await self.process_user(user_data)
print(f"✅ [{idx}/{len(users_to_process)}] Completed {user_name}")
return result
tasks = [
process_with_cache_check(idx, user)
process_with_cache_check(idx, user)
for idx, user in enumerate(users_to_process, 1)
]
await asyncio.gather(*tasks)
# Combine results
output_file = os.path.join(self.config.output_dir, "eval_results.jsonl")
self.file_manager.combine_results(output_file)
elapsed = time.time() - start_time
print(f"\n✅ Processing completed in {elapsed:.2f}s")
print(f"📁 Results: {output_file}\n")
# Aggregate metrics
await self.aggregate_and_report(output_file)
async def aggregate_and_report(self, results_file: str):
"""Aggregate results and generate final report."""
print("=" * 80)
print("AGGREGATING METRICS")
print("=" * 80 + "\n")
# Collect all QA records
qa_records = []
with open(results_file, "r", encoding="utf-8") as f:
@ -575,20 +575,20 @@ class HaluMemEvaluatorV4:
if not line.strip():
continue
user_data = json.loads(line)
for session in user_data["sessions"]:
if session.get("is_generated_qa_session"):
continue
eval_results = session.get("evaluation_results", {})
qa_records.extend(
eval_results.get("question_answering_records", [])
)
# Compute metrics
qa_metrics = MetricsAggregator.compute_qa_metrics(qa_records)
time_metrics = MetricsAggregator.compute_time_metrics(results_file)
final_results = {
"overall_score": {
"question_answering": qa_metrics,
@ -596,23 +596,23 @@ class HaluMemEvaluatorV4:
},
"question_answering_records": qa_records
}
# Save final report
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)
print(f"📊 Statistics saved to: {report_file}\n")
# Print summary
self._print_summary(qa_metrics, time_metrics)
def _print_summary(self, qa_metrics: dict, time_metrics: dict):
"""Print evaluation summary."""
print("=" * 80)
print("EVALUATION SUMMARY - REME")
print("=" * 80 + "\n")
print("📊 Question Answering:")
print(f" Correct (all): {qa_metrics['correct_qa_ratio(all)']:.4f}")
print(f" Hallucination (all): {qa_metrics['hallucination_qa_ratio(all)']:.4f}")
@ -621,7 +621,7 @@ class HaluMemEvaluatorV4:
print(f" Hallucination (valid): {qa_metrics['hallucination_qa_ratio(valid)']:.4f}")
print(f" Omission (valid): {qa_metrics['omission_qa_ratio(valid)']:.4f}")
print(f" Valid/Total: {qa_metrics['qa_valid_num']}/{qa_metrics['qa_num']}")
print(f"\n⏱️ Time 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")
@ -644,14 +644,14 @@ def main(
user_num=user_num,
max_concurrency=max_concurrency
)
evaluator = HaluMemEvaluatorV4(config)
asyncio.run(evaluator.run_evaluation())
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="Evaluate ReMe on HaluMem benchmark (Question Answering)"
)
@ -679,9 +679,9 @@ if __name__ == "__main__":
default=2,
help="Maximum concurrent user processing (default: 2)"
)
args = parser.parse_args()
main(
data_path=args.data_path,
top_k=args.top_k,

View file

@ -141,12 +141,12 @@ async def answer_question_with_memories(
):
"""
Answer a question using retrieved memories with PROMPT_MEMZERO_JSON template.
Args:
question: The question to answer
memories: The retrieved memories (formatted as context)
user_id: Optional user ID for context formatting
Returns:
dict with 'reasoning' and 'answer' fields
"""
@ -158,13 +158,13 @@ async def answer_question_with_memories(
)
else:
context = f"Memories:\n{memories}"
# Use PROMPT_MEMZERO_JSON template for structured JSON response
prompt = _PROMPTS["PROMPT_MEMZERO_JSON"].format(
context=context,
question=question
)
# result = await llm_request_for_json(prompt, model_name="qwen3-max")
result = await llm_request_for_json(prompt, model_name="qwen3-30b-a3b-instruct-2507")

View file

@ -11,7 +11,7 @@ PROMPT_MEMZERO_JSON: |
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}
@ -35,7 +35,7 @@ PROMPT_MEMZERO_JSON2: |
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}
@ -467,7 +467,7 @@ EVALUATION_PROMPT_FOR_QUESTION: |
"evaluation_result": "Correct | Hallucination | Omission"
}}
```
EVALUATION_PROMPT_FOR_QUESTION2: |
You are an **evaluation expert for AI memory system question answering**.

View file

@ -5,8 +5,8 @@ import re
from tenacity import retry, stop_after_attempt, wait_random_exponential, before_sleep_log
from reme_ai.core.schema import Message
from reme_ai.core.utils import load_env
from reme_ai.core_old.schema import Message
from reme_ai.core_old.utils import load_env
from reme_ai.reme import ReMe
logger = logging.getLogger(__name__)
@ -28,12 +28,12 @@ reme = ReMe()
)
async def llm_request(prompt, model_name: str = "qwen3-max", **kwargs) -> str:
"""Make an LLM request using ReMe's LLM with optional model override.
Args:
prompt: The prompt to send to the LLM
model_name: Optional model name to override the default model (default: "qwen3-max")
**kwargs: Additional arguments to pass to the chat method
Returns:
The assistant's response content
"""
@ -61,15 +61,15 @@ async def llm_request(prompt, model_name: str = "qwen3-max", **kwargs) -> str:
async def llm_request_for_json(prompt, model_name: str = "qwen-flash", **kwargs):
# async def llm_request_for_json(prompt, model_name: str = "qwen3-max", **kwargs):
"""Make an LLM request expecting JSON response using ReMe's LLM.
Args:
prompt: The prompt to send to the LLM
model_name: Optional model name to override the default model (default: "qwen3-max")
**kwargs: Additional arguments to pass to the chat method
Returns:
Parsed JSON object from the LLM response
Raises:
ValueError: If no JSON block is found in the model output
"""

View file

@ -22,9 +22,9 @@ def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
"qa_valid_num": 0,
"qa_num": 0
}
correct = hallucination = omission = valid = 0
for qa in qa_records:
result_type = qa.get("result_type", "")
if result_type == "Correct":
@ -36,7 +36,7 @@ def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
elif result_type == "Omission":
omission += 1
valid += 1
metrics = {
"correct_qa_ratio(all)": correct / total,
"hallucination_qa_ratio(all)": hallucination / total,
@ -47,21 +47,21 @@ def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
"qa_valid_num": valid,
"qa_num": total
}
return metrics
def compute_time_metrics(users_data: list[dict]) -> dict[str, float]:
"""Compute timing metrics from evaluation results."""
add_duration = search_duration = 0
for user_data in users_data:
for session in user_data.get("sessions", []):
add_duration += session.get("add_dialogue_duration_ms", 0)
eval_results = session.get("session", {}).get("evaluation_results", {})
for qa in eval_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,
@ -72,21 +72,21 @@ def compute_time_metrics(users_data: list[dict]) -> dict[str, float]:
def load_from_tmp_dir(tmp_dir: str) -> list[dict]:
"""Load data from tmp directory."""
tmp_path = Path(tmp_dir)
# Try flat file structure first (conversation_{user}_session_{idx}.json)
json_files = [f for f in tmp_path.iterdir() if f.is_file() and f.suffix == ".json"]
if json_files:
# Group files by user
users_dict = defaultdict(list)
for json_file in json_files:
with open(json_file, "r", encoding="utf-8") as f:
session_data = json.load(f)
user_name = session_data.get("user_name")
if user_name:
users_dict[user_name].append(session_data)
# Sort sessions by session_idx for each user
users_data = []
for user_name, sessions in users_dict.items():
@ -103,71 +103,71 @@ def load_from_tmp_dir(tmp_dir: str) -> list[dict]:
session_copy.pop("user_name", None)
user_data["sessions"].append(session_copy)
users_data.append(user_data)
return users_data
# Fallback to directory structure (user_name/session_{idx}.json)
user_dirs = [d for d in tmp_path.iterdir() if d.is_dir()]
users_data = []
for user_dir in user_dirs:
session_files = sorted(
[f for f in user_dir.iterdir() if "session_" in f.name and f.suffix == ".json"],
key=lambda f: int(f.stem.split("_")[-1])
)
if not session_files:
continue
with open(session_files[0], "r", encoding="utf-8") as f:
first_session = json.load(f)
user_data = {
"uuid": first_session["uuid"],
"user_name": first_session["user_name"],
"sessions": []
}
for session_file in session_files:
with open(session_file, "r", encoding="utf-8") as f:
session_data = json.load(f)
session_data.pop("uuid", None)
session_data.pop("user_name", None)
user_data["sessions"].append(session_data)
users_data.append(user_data)
return users_data
def main(tmp_dir: str):
"""Main function to compute statistics from tmp directory."""
tmp_path = Path(tmp_dir)
if not tmp_path.exists() or not tmp_path.is_dir():
print(f"❌ Error: Directory not found: {tmp_dir}")
return
# Load data from tmp directory
users_data = load_from_tmp_dir(tmp_dir)
# Collect QA records with metadata
qa_records = []
qa_with_metadata = []
user_count = session_count = 0
for user_data in users_data:
user_count += 1
user_name = user_data.get("user_name", "Unknown")
valid_session_idx = 0
for session in user_data.get("sessions", []):
if session.get("is_generated_qa_session"):
continue
session_count += 1
eval_results = session.get("session", {}).get("evaluation_results", {})
for qa_idx, qa in enumerate(eval_results.get("question_answering_records", [])):
qa_records.append(qa)
qa_with_metadata.append({
@ -176,17 +176,17 @@ def main(tmp_dir: str):
"question_idx": qa_idx,
"qa_record": qa
})
valid_session_idx += 1
# Compute metrics
qa_metrics = compute_qa_metrics(qa_records)
time_metrics = compute_time_metrics(users_data)
# Save results
output_dir = tmp_path.parent
report_file = output_dir / "reme_eval_stat_result.json"
final_results = {
"overall_score": {
"question_answering": qa_metrics,
@ -194,10 +194,10 @@ def main(tmp_dir: str):
},
"question_answering_records": qa_records
}
with open(report_file, "w", encoding="utf-8") as f:
json.dump(final_results, f, ensure_ascii=False, indent=4)
# Print summary
print(f"\n📊 Data: {user_count} users, {session_count} sessions, {len(qa_records)} QA records")
print(f"\n✅ Metrics:")
@ -205,12 +205,12 @@ def main(tmp_dir: str):
print(f" Valid: {qa_metrics['qa_valid_num']}/{qa_metrics['qa_num']}")
print(f"\n⏱️ Time: {time_metrics['total_duration_time']:.2f} min (Add: {time_metrics['add_dialogue_duration_time']:.2f} | Search: {time_metrics['search_memory_duration_time']:.2f})")
print(f"\n💾 Results saved: {report_file}")
# Print error records
print(f"\n{'='*80}\n❌ ERROR RECORDS ({len([r for r in qa_with_metadata if r['qa_record'].get('result_type') not in ['Correct', '']])} errors)\n{'='*80}")
error_records = [r for r in qa_with_metadata if r["qa_record"].get("result_type") not in ["Correct", ""]]
if error_records:
for idx, record in enumerate(error_records, 1):
qa = record["qa_record"]
@ -218,13 +218,13 @@ def main(tmp_dir: str):
print(f" Q: {qa.get('question', 'N/A')}")
print(f" Expected: {qa.get('answer', 'N/A')}")
print(f" Got: {qa.get('system_response', 'N/A')}")
print()
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Compute QA statistics from tmp directory")
parser.add_argument(
"tmp_dir",
@ -232,6 +232,6 @@ if __name__ == "__main__":
default="./data",
type=str,
help="Path to tmp directory containing user session data (default: ./data)")
args = parser.parse_args()
main(tmp_dir=args.tmp_dir)

View file

@ -64,7 +64,7 @@ EVALUATION_PROMPT_FOR_QUESTION: |
"evaluation_result": "Correct | Hallucination | Omission"
}}
```
EVALUATION_PROMPT_FOR_QUESTION2: |
You are an **evaluation expert for AI memory system question answering**.

View file

@ -16,8 +16,8 @@ from collections import defaultdict
from pathlib import Path
from typing import Any
from reme_ai.core.schema import Message
from reme_ai.core.utils import load_env
from reme_ai.core_old.schema import Message
from reme_ai.core_old.utils import load_env
from reme_ai.reme import ReMe
from tenacity import retry, stop_after_attempt, wait_random_exponential
@ -82,7 +82,7 @@ async def evaluate_qa_record(
prompt_version: str = "v1"
) -> dict:
"""Evaluate a single QA record using LLM with specified prompt version.
Args:
question: The question to evaluate
reference_answer: The reference answer
@ -90,9 +90,9 @@ async def evaluate_qa_record(
response: System response to evaluate
dialogue: Dialogue context (optional)
model_name: LLM model name
prompt_version: "v1" for EVALUATION_PROMPT_FOR_QUESTION,
prompt_version: "v1" for EVALUATION_PROMPT_FOR_QUESTION,
"v2" for EVALUATION_PROMPT_FOR_QUESTION2
Returns:
dict with evaluation_result and reasoning
"""
@ -101,7 +101,7 @@ async def evaluate_qa_record(
prompt_template = _PROMPTS["EVALUATION_PROMPT_FOR_QUESTION2"]
else:
prompt_template = _PROMPTS["EVALUATION_PROMPT_FOR_QUESTION"]
# Format prompt
prompt = prompt_template.format(
question=question,
@ -110,7 +110,7 @@ async def evaluate_qa_record(
response=response,
dialogue=dialogue or "N/A"
)
result = await llm_request_for_json(prompt, model_name=model_name)
return result
@ -118,14 +118,14 @@ async def evaluate_qa_record(
def load_from_data_dir(data_dir: str) -> list[dict]:
"""Load data from data directory (same as compute_qa_stats.py)."""
data_path = Path(data_dir)
# Try flat file structure first (conversation_{user}_session_{idx}.json)
json_files = [f for f in data_path.iterdir() if f.is_file() and f.suffix == ".json"]
if json_files:
# Group files by user
users_dict = defaultdict(list)
for json_file in json_files:
with open(json_file, "r", encoding="utf-8") as f:
session_data = json.load(f)
@ -135,18 +135,18 @@ def load_from_data_dir(data_dir: str) -> list[dict]:
"file": json_file,
"data": session_data
})
# Sort sessions by session_idx for each user
users_data = []
for user_name, sessions in users_dict.items():
sessions_sorted = sorted(
sessions,
sessions,
key=lambda s: s["data"].get("session_idx", 0)
)
users_data.extend(sessions_sorted)
return users_data
return []
@ -155,7 +155,7 @@ def format_dialogue_context(session_data: dict) -> str:
dialogue = session_data.get("session", {}).get("dialogue", [])
if not dialogue:
return "N/A"
formatted_turns = []
for turn in dialogue:
role = turn.get("role", "unknown")
@ -175,34 +175,34 @@ async def reevaluate_session(
parallel: bool = True
) -> dict:
"""Re-evaluate all QA records in a session using multiple models and prompts.
Args:
session_file: Path to session file
session_data: Session data dict
models: List of model names to use for evaluation
prompt_versions: List of prompt versions ("v1", "v2")
parallel: If True, use asyncio.gather for parallel execution;
parallel: If True, use asyncio.gather for parallel execution;
if False, execute sequentially
Returns:
Updated session data with evaluation results for each model+prompt combination
Note:
Request rate limiting is handled by base_llm.py's request_interval mechanism.
"""
eval_results = session_data.get("session", {}).get("evaluation_results", {})
qa_records = eval_results.get("question_answering_records", [])
if not qa_records:
print(f" ⏭️ No QA records found")
return session_data
total_evals = len(models) * len(prompt_versions) * len(qa_records)
print(f" 🔍 Re-evaluating {len(qa_records)} QA records with {len(models)} models × {len(prompt_versions)} prompts = {total_evals} evaluations...")
# Format dialogue context once
dialogue_context = format_dialogue_context(session_data)
async def evaluate_single_combination(
idx: int,
qa: dict,
@ -210,19 +210,19 @@ async def reevaluate_session(
prompt_version: str
) -> tuple[int, str, str, dict]:
"""Evaluate a single QA record with specific model and prompt.
Note: Rate limiting is handled by BaseLLM's request_interval mechanism.
"""
question = qa.get("question", "")
reference_answer = qa.get("answer", "")
# Get key memory points from evidence
evidence = qa.get("evidence", [])
key_memory_points = "\n".join([e.get("memory_content", "") for e in evidence])
# Get system response
system_response = qa.get("system_response", "")
try:
# Call LLM for evaluation
eval_result = await evaluate_qa_record(
@ -234,28 +234,28 @@ async def reevaluate_session(
model_name=model_name,
prompt_version=prompt_version
)
result = {
"result_type": eval_result.get("evaluation_result", "Invalid"),
"reasoning": eval_result.get("reasoning", "")
}
return idx, model_name, prompt_version, result
except Exception as e:
print(f" ❌ QA[{idx+1}] {model_name}/{prompt_version}: Error: {e}")
return idx, model_name, prompt_version, {
"result_type": "Error",
"reasoning": f"Evaluation error: {str(e)}"
}
# Create all evaluation tasks (all combinations of models, prompts, and QA records)
tasks = []
for idx, qa in enumerate(qa_records):
for model_name in models:
for prompt_version in prompt_versions:
tasks.append(evaluate_single_combination(idx, qa, model_name, prompt_version))
# Execute evaluations based on parallel mode
if parallel:
print(f" ⚡ Starting {len(tasks)} parallel evaluations (rate limited by LLM layer)...")
@ -268,18 +268,18 @@ async def reevaluate_session(
results.append(result)
if i % 10 == 0 or i == len(tasks):
print(f" ⏳ Progress: {i}/{len(tasks)} evaluations completed")
# Organize results by QA index, then by model and prompt
# Structure: qa_records[idx]["evaluations"][model][prompt_version] = {result_type, reasoning}
for idx, qa in enumerate(qa_records):
if "evaluations" not in qa:
qa["evaluations"] = {}
# Initialize evaluations structure
for model_name in models:
if model_name not in qa["evaluations"]:
qa["evaluations"][model_name] = {}
# Fill in results
completed_count = 0
for qa_idx, model_name, prompt_version, result in results:
@ -287,7 +287,7 @@ async def reevaluate_session(
completed_count += 1
if completed_count % 10 == 0 or completed_count == len(results):
print(f" ✅ Completed {completed_count}/{len(results)} evaluations")
# Set default result_type to first model's v1 result for compatibility
if models and prompt_versions:
default_model = models[0]
@ -296,21 +296,21 @@ async def reevaluate_session(
default_eval = qa["evaluations"].get(default_model, {}).get(default_prompt, {})
qa["result_type"] = default_eval.get("result_type", "Invalid")
qa["question_answering_reasoning"] = default_eval.get("reasoning", "")
# Update session data
if "session" not in session_data:
session_data["session"] = {}
if "evaluation_results" not in session_data["session"]:
session_data["session"]["evaluation_results"] = {}
session_data["session"]["evaluation_results"]["question_answering_records"] = qa_records
# Save updated session data
with open(session_file, "w", encoding="utf-8") as f:
json.dump(session_data, f, ensure_ascii=False, indent=2)
print(f" 💾 Updated session saved with all evaluations")
return session_data
@ -328,9 +328,9 @@ def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
"qa_valid_num": 0,
"qa_num": 0
}
correct = hallucination = omission = valid = 0
for qa in qa_records:
result_type = qa.get("result_type", "")
if result_type == "Correct":
@ -342,7 +342,7 @@ def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
elif result_type == "Omission":
omission += 1
valid += 1
metrics = {
"correct_qa_ratio(all)": correct / total,
"hallucination_qa_ratio(all)": hallucination / total,
@ -353,7 +353,7 @@ def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
"qa_valid_num": valid,
"qa_num": total
}
return metrics
@ -364,28 +364,28 @@ async def main(
parallel: bool = True
):
"""Main function to re-evaluate QA records from data directory with multiple models and prompts.
Args:
data_dir: Path to data directory
models: List of model names (e.g., ["qwen3-max", "qwen-flash"])
prompt_versions: List of prompt versions (e.g., ["v1", "v2"])
parallel: If True, use parallel execution; if False, use sequential execution
Note:
Request rate limiting is automatically handled by base_llm.py's request_interval mechanism.
"""
data_path = Path(data_dir)
if not data_path.exists() or not data_path.is_dir():
print(f"❌ Error: Directory not found: {data_dir}")
return
# Default values
if models is None:
models = ["qwen3-max"]
if prompt_versions is None:
prompt_versions = ["v1"]
print("=" * 80)
print("RE-EVALUATING QA RECORDS WITH MULTIPLE MODELS & PROMPTS")
print(f"Models: {', '.join(models)}")
@ -393,27 +393,27 @@ async def main(
print(f"Execution mode: {'Parallel' if parallel else 'Sequential'}")
print("Note: Request rate limiting handled by LLM layer (base_llm.py)")
print("=" * 80 + "\n")
# Load data from directory
sessions = load_from_data_dir(data_dir)
if not sessions:
print(f"❌ No session files found in {data_dir}")
return
print(f"📂 Found {len(sessions)} session files\n")
# Process each session
all_qa_records = []
for idx, session_info in enumerate(sessions, 1):
session_file = session_info["file"]
session_data = session_info["data"]
user_name = session_data.get("user_name", "Unknown")
session_idx = session_data.get("session_idx", 0)
print(f"[{idx}/{len(sessions)}] {user_name} - Session {session_idx}")
updated_session = await reevaluate_session(
session_file=session_file,
session_data=session_data,
@ -421,25 +421,25 @@ async def main(
prompt_versions=prompt_versions,
parallel=parallel
)
# Collect QA records for metrics
eval_results = updated_session.get("session", {}).get("evaluation_results", {})
qa_records = eval_results.get("question_answering_records", [])
all_qa_records.extend(qa_records)
print()
# Compute and display metrics for each model+prompt combination
print("=" * 80)
print("UPDATED METRICS (BY MODEL & PROMPT)")
print("=" * 80 + "\n")
for model_name in models:
for prompt_version in prompt_versions:
prompt_name = "EVALUATION_PROMPT_FOR_QUESTION" if prompt_version == "v1" else "EVALUATION_PROMPT_FOR_QUESTION2"
print(f"\n📊 {model_name} / {prompt_name}:")
print("" * 80)
# Extract QA records for this model+prompt combination
model_qa_records = []
for qa in all_qa_records:
@ -452,10 +452,10 @@ async def main(
"question_answering_reasoning": eval_data.get("reasoning", "")
}
model_qa_records.append(qa_copy)
if model_qa_records:
metrics = compute_qa_metrics(model_qa_records)
print(f" Correct (all): {metrics['correct_qa_ratio(all)']:.4f}")
print(f" Hallucination (all): {metrics['hallucination_qa_ratio(all)']:.4f}")
print(f" Omission (all): {metrics['omission_qa_ratio(all)']:.4f}")
@ -463,17 +463,17 @@ async def main(
print(f" Hallucination (valid): {metrics['hallucination_qa_ratio(valid)']:.4f}")
print(f" Omission (valid): {metrics['omission_qa_ratio(valid)']:.4f}")
print(f" Valid/Total: {metrics['qa_valid_num']}/{metrics['qa_num']}")
# Save detailed results with all evaluations
report_file = data_path.parent / "reme_eval_stat_result_detailed.json"
# Create summary for each model+prompt combination
evaluation_summary = {}
for model_name in models:
evaluation_summary[model_name] = {}
for prompt_version in prompt_versions:
prompt_name = "EVALUATION_PROMPT_FOR_QUESTION" if prompt_version == "v1" else "EVALUATION_PROMPT_FOR_QUESTION2"
# Extract QA records for this combination
model_qa_records = []
for qa in all_qa_records:
@ -485,28 +485,28 @@ async def main(
"question_answering_reasoning": eval_data.get("reasoning", "")
}
model_qa_records.append(qa_copy)
metrics = compute_qa_metrics(model_qa_records)
evaluation_summary[model_name][prompt_name] = {
"metrics": metrics,
"qa_records": model_qa_records
}
final_results = {
"evaluation_summary": evaluation_summary,
"all_qa_records_with_evaluations": all_qa_records
}
with open(report_file, "w", encoding="utf-8") as f:
json.dump(final_results, f, ensure_ascii=False, indent=2)
print(f"\n💾 Detailed results saved: {report_file}")
print("\n" + "=" * 80)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="Re-evaluate QA records from data directory using multiple models and prompts in parallel. "
"Request rate limiting is automatically handled by base_llm.py's request_interval mechanism."
@ -539,9 +539,9 @@ if __name__ == "__main__":
action="store_true",
help="Use sequential execution instead of parallel (default: parallel)"
)
args = parser.parse_args()
asyncio.run(main(
data_dir=args.data_dir,
models=args.models,

View file

@ -22,9 +22,9 @@ def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
"qa_valid_num": 0,
"qa_num": 0
}
correct = hallucination = omission = valid = 0
for qa in qa_records:
result_type = qa.get("result_type", "")
if result_type == "Correct":
@ -36,7 +36,7 @@ def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
elif result_type == "Omission":
omission += 1
valid += 1
metrics = {
"correct_qa_ratio(all)": correct / total,
"hallucination_qa_ratio(all)": hallucination / total,
@ -47,21 +47,21 @@ def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
"qa_valid_num": valid,
"qa_num": total
}
return metrics
def compute_time_metrics(users_data: list[dict]) -> dict[str, float]:
"""Compute timing metrics from evaluation results."""
add_duration = search_duration = 0
for user_data in users_data:
for session in user_data.get("sessions", []):
add_duration += session.get("add_dialogue_duration_ms", 0)
eval_results = session.get("session", {}).get("evaluation_results", {})
for qa in eval_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,
@ -72,21 +72,21 @@ def compute_time_metrics(users_data: list[dict]) -> dict[str, float]:
def load_from_tmp_dir(tmp_dir: str) -> list[dict]:
"""Load data from tmp directory."""
tmp_path = Path(tmp_dir)
# Try flat file structure first (conversation_{user}_session_{idx}.json)
json_files = [f for f in tmp_path.iterdir() if f.is_file() and f.suffix == ".json"]
if json_files:
# Group files by user
users_dict = defaultdict(list)
for json_file in json_files:
with open(json_file, "r", encoding="utf-8") as f:
session_data = json.load(f)
user_name = session_data.get("user_name")
if user_name:
users_dict[user_name].append(session_data)
# Sort sessions by session_idx for each user
users_data = []
for user_name, sessions in users_dict.items():
@ -103,71 +103,71 @@ def load_from_tmp_dir(tmp_dir: str) -> list[dict]:
session_copy.pop("user_name", None)
user_data["sessions"].append(session_copy)
users_data.append(user_data)
return users_data
# Fallback to directory structure (user_name/session_{idx}.json)
user_dirs = [d for d in tmp_path.iterdir() if d.is_dir()]
users_data = []
for user_dir in user_dirs:
session_files = sorted(
[f for f in user_dir.iterdir() if "session_" in f.name and f.suffix == ".json"],
key=lambda f: int(f.stem.split("_")[-1])
)
if not session_files:
continue
with open(session_files[0], "r", encoding="utf-8") as f:
first_session = json.load(f)
user_data = {
"uuid": first_session["uuid"],
"user_name": first_session["user_name"],
"sessions": []
}
for session_file in session_files:
with open(session_file, "r", encoding="utf-8") as f:
session_data = json.load(f)
session_data.pop("uuid", None)
session_data.pop("user_name", None)
user_data["sessions"].append(session_data)
users_data.append(user_data)
return users_data
def main(tmp_dir: str):
"""Main function to compute statistics from tmp directory."""
tmp_path = Path(tmp_dir)
if not tmp_path.exists() or not tmp_path.is_dir():
print(f"❌ Error: Directory not found: {tmp_dir}")
return
# Load data from tmp directory
users_data = load_from_tmp_dir(tmp_dir)
# Collect QA records with metadata
qa_records = []
qa_with_metadata = []
user_count = session_count = 0
for user_data in users_data:
user_count += 1
user_name = user_data.get("user_name", "Unknown")
valid_session_idx = 0
for session in user_data.get("sessions", []):
if session.get("is_generated_qa_session"):
continue
session_count += 1
eval_results = session.get("session", {}).get("evaluation_results", {})
for qa_idx, qa in enumerate(eval_results.get("question_answering_records", [])):
qa_records.append(qa)
qa_with_metadata.append({
@ -176,17 +176,17 @@ def main(tmp_dir: str):
"question_idx": qa_idx,
"qa_record": qa
})
valid_session_idx += 1
# Compute metrics
qa_metrics = compute_qa_metrics(qa_records)
time_metrics = compute_time_metrics(users_data)
# Save results
output_dir = tmp_path.parent
report_file = output_dir / "reme_eval_stat_result.json"
final_results = {
"overall_score": {
"question_answering": qa_metrics,
@ -194,10 +194,10 @@ def main(tmp_dir: str):
},
"question_answering_records": qa_records
}
with open(report_file, "w", encoding="utf-8") as f:
json.dump(final_results, f, ensure_ascii=False, indent=4)
# Print summary
print(f"\n📊 Data: {user_count} users, {session_count} sessions, {len(qa_records)} QA records")
print(f"\n✅ Metrics:")
@ -205,12 +205,12 @@ def main(tmp_dir: str):
print(f" Valid: {qa_metrics['qa_valid_num']}/{qa_metrics['qa_num']}")
print(f"\n⏱️ Time: {time_metrics['total_duration_time']:.2f} min (Add: {time_metrics['add_dialogue_duration_time']:.2f} | Search: {time_metrics['search_memory_duration_time']:.2f})")
print(f"\n💾 Results saved: {report_file}")
# Print error records
print(f"\n{'='*80}\n❌ ERROR RECORDS ({len([r for r in qa_with_metadata if r['qa_record'].get('result_type') not in ['Correct', '']])} errors)\n{'='*80}")
error_records = [r for r in qa_with_metadata if r["qa_record"].get("result_type") not in ["Correct", ""]]
if error_records:
for idx, record in enumerate(error_records, 1):
qa = record["qa_record"]
@ -218,13 +218,13 @@ def main(tmp_dir: str):
print(f" Q: {qa.get('question', 'N/A')}")
print(f" Expected: {qa.get('answer', 'N/A')}")
print(f" Got: {qa.get('system_response', 'N/A')}")
print()
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Compute QA statistics from tmp directory")
parser.add_argument(
"tmp_dir",
@ -232,6 +232,6 @@ if __name__ == "__main__":
default="./data",
type=str,
help="Path to tmp directory containing user session data (default: ./data)")
args = parser.parse_args()
main(tmp_dir=args.tmp_dir)

View file

@ -64,7 +64,7 @@ EVALUATION_PROMPT_FOR_QUESTION: |
"evaluation_result": "Correct | Hallucination | Omission"
}}
```
EVALUATION_PROMPT_FOR_QUESTION2: |
You are an **evaluation expert for AI memory system question answering**.

View file

@ -16,8 +16,8 @@ from collections import defaultdict
from pathlib import Path
from typing import Any
from reme_ai.core.schema import Message
from reme_ai.core.utils import load_env
from reme_ai.core_old.schema import Message
from reme_ai.core_old.utils import load_env
from reme_ai.reme import ReMe
from tenacity import retry, stop_after_attempt, wait_random_exponential
@ -82,7 +82,7 @@ async def evaluate_qa_record(
prompt_version: str = "v1"
) -> dict:
"""Evaluate a single QA record using LLM with specified prompt version.
Args:
question: The question to evaluate
reference_answer: The reference answer
@ -90,9 +90,9 @@ async def evaluate_qa_record(
response: System response to evaluate
dialogue: Dialogue context (optional)
model_name: LLM model name
prompt_version: "v1" for EVALUATION_PROMPT_FOR_QUESTION,
prompt_version: "v1" for EVALUATION_PROMPT_FOR_QUESTION,
"v2" for EVALUATION_PROMPT_FOR_QUESTION2
Returns:
dict with evaluation_result and reasoning
"""
@ -101,7 +101,7 @@ async def evaluate_qa_record(
prompt_template = _PROMPTS["EVALUATION_PROMPT_FOR_QUESTION2"]
else:
prompt_template = _PROMPTS["EVALUATION_PROMPT_FOR_QUESTION"]
# Format prompt
prompt = prompt_template.format(
question=question,
@ -110,7 +110,7 @@ async def evaluate_qa_record(
response=response,
dialogue=dialogue or "N/A"
)
result = await llm_request_for_json(prompt, model_name=model_name)
return result
@ -118,14 +118,14 @@ async def evaluate_qa_record(
def load_from_data_dir(data_dir: str) -> list[dict]:
"""Load data from data directory (same as compute_qa_stats.py)."""
data_path = Path(data_dir)
# Try flat file structure first (conversation_{user}_session_{idx}.json)
json_files = [f for f in data_path.iterdir() if f.is_file() and f.suffix == ".json"]
if json_files:
# Group files by user
users_dict = defaultdict(list)
for json_file in json_files:
with open(json_file, "r", encoding="utf-8") as f:
session_data = json.load(f)
@ -135,18 +135,18 @@ def load_from_data_dir(data_dir: str) -> list[dict]:
"file": json_file,
"data": session_data
})
# Sort sessions by session_idx for each user
users_data = []
for user_name, sessions in users_dict.items():
sessions_sorted = sorted(
sessions,
sessions,
key=lambda s: s["data"].get("session_idx", 0)
)
users_data.extend(sessions_sorted)
return users_data
return []
@ -155,7 +155,7 @@ def format_dialogue_context(session_data: dict) -> str:
dialogue = session_data.get("session", {}).get("dialogue", [])
if not dialogue:
return "N/A"
formatted_turns = []
for turn in dialogue:
role = turn.get("role", "unknown")
@ -175,34 +175,34 @@ async def reevaluate_session(
parallel: bool = True
) -> dict:
"""Re-evaluate all QA records in a session using multiple models and prompts.
Args:
session_file: Path to session file
session_data: Session data dict
models: List of model names to use for evaluation
prompt_versions: List of prompt versions ("v1", "v2")
parallel: If True, use asyncio.gather for parallel execution;
parallel: If True, use asyncio.gather for parallel execution;
if False, execute sequentially
Returns:
Updated session data with evaluation results for each model+prompt combination
Note:
Request rate limiting is handled by base_llm.py's request_interval mechanism.
"""
eval_results = session_data.get("session", {}).get("evaluation_results", {})
qa_records = eval_results.get("question_answering_records", [])
if not qa_records:
print(f" ⏭️ No QA records found")
return session_data
total_evals = len(models) * len(prompt_versions) * len(qa_records)
print(f" 🔍 Re-evaluating {len(qa_records)} QA records with {len(models)} models × {len(prompt_versions)} prompts = {total_evals} evaluations...")
# Format dialogue context once
dialogue_context = format_dialogue_context(session_data)
async def evaluate_single_combination(
idx: int,
qa: dict,
@ -210,19 +210,19 @@ async def reevaluate_session(
prompt_version: str
) -> tuple[int, str, str, dict]:
"""Evaluate a single QA record with specific model and prompt.
Note: Rate limiting is handled by BaseLLM's request_interval mechanism.
"""
question = qa.get("question", "")
reference_answer = qa.get("answer", "")
# Get key memory points from evidence
evidence = qa.get("evidence", [])
key_memory_points = "\n".join([e.get("memory_content", "") for e in evidence])
# Get system response
system_response = qa.get("system_response", "")
try:
# Call LLM for evaluation
eval_result = await evaluate_qa_record(
@ -234,28 +234,28 @@ async def reevaluate_session(
model_name=model_name,
prompt_version=prompt_version
)
result = {
"result_type": eval_result.get("evaluation_result", "Invalid"),
"reasoning": eval_result.get("reasoning", "")
}
return idx, model_name, prompt_version, result
except Exception as e:
print(f" ❌ QA[{idx+1}] {model_name}/{prompt_version}: Error: {e}")
return idx, model_name, prompt_version, {
"result_type": "Error",
"reasoning": f"Evaluation error: {str(e)}"
}
# Create all evaluation tasks (all combinations of models, prompts, and QA records)
tasks = []
for idx, qa in enumerate(qa_records):
for model_name in models:
for prompt_version in prompt_versions:
tasks.append(evaluate_single_combination(idx, qa, model_name, prompt_version))
# Execute evaluations based on parallel mode
if parallel:
print(f" ⚡ Starting {len(tasks)} parallel evaluations (rate limited by LLM layer)...")
@ -268,18 +268,18 @@ async def reevaluate_session(
results.append(result)
if i % 10 == 0 or i == len(tasks):
print(f" ⏳ Progress: {i}/{len(tasks)} evaluations completed")
# Organize results by QA index, then by model and prompt
# Structure: qa_records[idx]["evaluations"][model][prompt_version] = {result_type, reasoning}
for idx, qa in enumerate(qa_records):
if "evaluations" not in qa:
qa["evaluations"] = {}
# Initialize evaluations structure
for model_name in models:
if model_name not in qa["evaluations"]:
qa["evaluations"][model_name] = {}
# Fill in results
completed_count = 0
for qa_idx, model_name, prompt_version, result in results:
@ -287,7 +287,7 @@ async def reevaluate_session(
completed_count += 1
if completed_count % 10 == 0 or completed_count == len(results):
print(f" ✅ Completed {completed_count}/{len(results)} evaluations")
# Set default result_type to first model's v1 result for compatibility
if models and prompt_versions:
default_model = models[0]
@ -296,21 +296,21 @@ async def reevaluate_session(
default_eval = qa["evaluations"].get(default_model, {}).get(default_prompt, {})
qa["result_type"] = default_eval.get("result_type", "Invalid")
qa["question_answering_reasoning"] = default_eval.get("reasoning", "")
# Update session data
if "session" not in session_data:
session_data["session"] = {}
if "evaluation_results" not in session_data["session"]:
session_data["session"]["evaluation_results"] = {}
session_data["session"]["evaluation_results"]["question_answering_records"] = qa_records
# Save updated session data
with open(session_file, "w", encoding="utf-8") as f:
json.dump(session_data, f, ensure_ascii=False, indent=2)
print(f" 💾 Updated session saved with all evaluations")
return session_data
@ -328,9 +328,9 @@ def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
"qa_valid_num": 0,
"qa_num": 0
}
correct = hallucination = omission = valid = 0
for qa in qa_records:
result_type = qa.get("result_type", "")
if result_type == "Correct":
@ -342,7 +342,7 @@ def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
elif result_type == "Omission":
omission += 1
valid += 1
metrics = {
"correct_qa_ratio(all)": correct / total,
"hallucination_qa_ratio(all)": hallucination / total,
@ -353,7 +353,7 @@ def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
"qa_valid_num": valid,
"qa_num": total
}
return metrics
@ -364,28 +364,28 @@ async def main(
parallel: bool = True
):
"""Main function to re-evaluate QA records from data directory with multiple models and prompts.
Args:
data_dir: Path to data directory
models: List of model names (e.g., ["qwen3-max", "qwen-flash"])
prompt_versions: List of prompt versions (e.g., ["v1", "v2"])
parallel: If True, use parallel execution; if False, use sequential execution
Note:
Request rate limiting is automatically handled by base_llm.py's request_interval mechanism.
"""
data_path = Path(data_dir)
if not data_path.exists() or not data_path.is_dir():
print(f"❌ Error: Directory not found: {data_dir}")
return
# Default values
if models is None:
models = ["qwen3-max"]
if prompt_versions is None:
prompt_versions = ["v1"]
print("=" * 80)
print("RE-EVALUATING QA RECORDS WITH MULTIPLE MODELS & PROMPTS")
print(f"Models: {', '.join(models)}")
@ -393,27 +393,27 @@ async def main(
print(f"Execution mode: {'Parallel' if parallel else 'Sequential'}")
print("Note: Request rate limiting handled by LLM layer (base_llm.py)")
print("=" * 80 + "\n")
# Load data from directory
sessions = load_from_data_dir(data_dir)
if not sessions:
print(f"❌ No session files found in {data_dir}")
return
print(f"📂 Found {len(sessions)} session files\n")
# Process each session
all_qa_records = []
for idx, session_info in enumerate(sessions, 1):
session_file = session_info["file"]
session_data = session_info["data"]
user_name = session_data.get("user_name", "Unknown")
session_idx = session_data.get("session_idx", 0)
print(f"[{idx}/{len(sessions)}] {user_name} - Session {session_idx}")
updated_session = await reevaluate_session(
session_file=session_file,
session_data=session_data,
@ -421,25 +421,25 @@ async def main(
prompt_versions=prompt_versions,
parallel=parallel
)
# Collect QA records for metrics
eval_results = updated_session.get("session", {}).get("evaluation_results", {})
qa_records = eval_results.get("question_answering_records", [])
all_qa_records.extend(qa_records)
print()
# Compute and display metrics for each model+prompt combination
print("=" * 80)
print("UPDATED METRICS (BY MODEL & PROMPT)")
print("=" * 80 + "\n")
for model_name in models:
for prompt_version in prompt_versions:
prompt_name = "EVALUATION_PROMPT_FOR_QUESTION" if prompt_version == "v1" else "EVALUATION_PROMPT_FOR_QUESTION2"
print(f"\n📊 {model_name} / {prompt_name}:")
print("" * 80)
# Extract QA records for this model+prompt combination
model_qa_records = []
for qa in all_qa_records:
@ -452,10 +452,10 @@ async def main(
"question_answering_reasoning": eval_data.get("reasoning", "")
}
model_qa_records.append(qa_copy)
if model_qa_records:
metrics = compute_qa_metrics(model_qa_records)
print(f" Correct (all): {metrics['correct_qa_ratio(all)']:.4f}")
print(f" Hallucination (all): {metrics['hallucination_qa_ratio(all)']:.4f}")
print(f" Omission (all): {metrics['omission_qa_ratio(all)']:.4f}")
@ -463,17 +463,17 @@ async def main(
print(f" Hallucination (valid): {metrics['hallucination_qa_ratio(valid)']:.4f}")
print(f" Omission (valid): {metrics['omission_qa_ratio(valid)']:.4f}")
print(f" Valid/Total: {metrics['qa_valid_num']}/{metrics['qa_num']}")
# Save detailed results with all evaluations
report_file = data_path.parent / "reme_eval_stat_result_detailed.json"
# Create summary for each model+prompt combination
evaluation_summary = {}
for model_name in models:
evaluation_summary[model_name] = {}
for prompt_version in prompt_versions:
prompt_name = "EVALUATION_PROMPT_FOR_QUESTION" if prompt_version == "v1" else "EVALUATION_PROMPT_FOR_QUESTION2"
# Extract QA records for this combination
model_qa_records = []
for qa in all_qa_records:
@ -485,28 +485,28 @@ async def main(
"question_answering_reasoning": eval_data.get("reasoning", "")
}
model_qa_records.append(qa_copy)
metrics = compute_qa_metrics(model_qa_records)
evaluation_summary[model_name][prompt_name] = {
"metrics": metrics,
"qa_records": model_qa_records
}
final_results = {
"evaluation_summary": evaluation_summary,
"all_qa_records_with_evaluations": all_qa_records
}
with open(report_file, "w", encoding="utf-8") as f:
json.dump(final_results, f, ensure_ascii=False, indent=2)
print(f"\n💾 Detailed results saved: {report_file}")
print("\n" + "=" * 80)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="Re-evaluate QA records from data directory using multiple models and prompts in parallel. "
"Request rate limiting is automatically handled by base_llm.py's request_interval mechanism."
@ -539,9 +539,9 @@ if __name__ == "__main__":
action="store_true",
help="Use sequential execution instead of parallel (default: parallel)"
)
args = parser.parse_args()
asyncio.run(main(
data_dir=args.data_dir,
models=args.models,

3
docs/todo.md Normal file
View file

@ -0,0 +1,3 @@
1. 如何更好的注册class
2. op的返回使用return 还是 self.output
3. 如何把agent的东西放出来

View file

@ -1,17 +0,0 @@
"""Core module for ReMe AI framework."""
# pylint: disable=wrong-import-position
# flake8: noqa: F401
from . import config
from . import context
from . import embedding
from . import enumeration
from . import flow
from . import llm
from . import op
from . import schema
from . import service
from . import token_counter
from . import utils
from . import vector_store

View file

@ -1,24 +1,100 @@
"""Module for managing and formatting prompt templates from files or dictionaries."""
"""Module for managing and formatting prompt templates from files or dictionaries.
This module provides a PromptHandler class that:
- Loads prompts from YAML/JSON files or dictionaries
- Supports multi-language prompts with automatic suffix handling
- Provides conditional line filtering using boolean flags
- Formats prompts with template variable substitution
- Validates format strings and provides helpful error messages
"""
import json
from pathlib import Path
from string import Formatter
from typing import Any, Dict, Optional, Union
import yaml
from loguru import logger
from .base_context import BaseContext
from .service_context import C
class PromptNotFoundError(KeyError):
"""Exception raised when a requested prompt template is not found."""
def __init__(self, prompt_name: str, available_prompts: list[str]):
self.prompt_name = prompt_name
self.available_prompts = available_prompts
super().__init__(
f"Prompt '{prompt_name}' not found. "
f"Available prompts: {', '.join(available_prompts[:10])}"
f"{'...' if len(available_prompts) > 10 else ''}"
)
class PromptFormattingError(ValueError):
"""Exception raised when prompt formatting fails."""
pass
class PromptHandler(BaseContext):
"""A context-aware handler for loading, retrieving, and formatting prompt templates."""
"""A context-aware handler for loading, retrieving, and formatting prompt templates.
This handler supports:
- Loading prompts from YAML/JSON files or dictionaries
- Multi-language prompt support with automatic language suffix
- Conditional line filtering using boolean flags (e.g., [debug], [verbose])
- Template variable substitution with validation
- Method chaining for fluent API
Examples:
>>> handler = PromptHandler(language="en")
>>> handler.load_prompt_dict({
... "greeting_en": "Hello, {name}!",
... "farewell_en": "[debug]Debug mode\\nGoodbye, {name}!"
... })
>>> handler.prompt_format("greeting", name="Alice")
'Hello, Alice!'
>>> handler.prompt_format("farewell", name="Bob", debug=False)
'Goodbye, Bob!'
"""
def __init__(self, language: str = "", **kwargs):
"""Initialize the handler with a specific language and optional context data."""
"""Initialize the PromptHandler with optional language configuration.
Args:
language: Language code to append as suffix (e.g., "en", "zh", "ja").
If provided, get_prompt will automatically try to find
prompts with this suffix (e.g., "greeting" -> "greeting_en").
**kwargs: Additional key-value pairs to initialize the context.
"""
super().__init__(**kwargs)
self.language: str = language or C.language
self.language: str = language.strip()
def load_prompt_by_file(self, prompt_file_path: Path | str = None):
"""Load prompt configurations from a YAML file into the context."""
def load_prompt_by_file(
self,
prompt_file_path: Optional[Union[Path, str]] = None,
overwrite: bool = True
) -> "PromptHandler":
"""Load prompt configurations from a YAML or JSON file into the context.
Supports both YAML (.yaml, .yml) and JSON (.json) file formats.
Non-existent files are silently skipped.
Args:
prompt_file_path: Path to the prompt configuration file.
If None, returns self without changes.
overwrite: If True, allows overwriting existing prompts with warnings.
If False, skips existing prompts without overwriting.
Returns:
Self for method chaining.
Raises:
ValueError: If file format is not supported.
yaml.YAMLError: If YAML parsing fails.
json.JSONDecodeError: If JSON parsing fails.
"""
if prompt_file_path is None:
return self
@ -26,70 +102,272 @@ class PromptHandler(BaseContext):
prompt_file_path = Path(prompt_file_path)
if not prompt_file_path.exists():
logger.warning(f"Prompt file not found: {prompt_file_path}")
return self
with prompt_file_path.open(encoding="utf-8") as f:
# Load YAML content using the full loader
prompt_dict = yaml.load(f, yaml.FullLoader)
self.load_prompt_dict(prompt_dict)
suffix = prompt_file_path.suffix.lower()
try:
with prompt_file_path.open(encoding="utf-8") as f:
if suffix in [".yaml", ".yml"]:
prompt_dict = yaml.safe_load(f)
elif suffix == ".json":
prompt_dict = json.load(f)
else:
raise ValueError(
f"Unsupported file format: {suffix}. "
f"Supported formats: .yaml, .yml, .json"
)
logger.info(f"Loaded {len(prompt_dict or {})} prompts from {prompt_file_path}")
self.load_prompt_dict(prompt_dict, overwrite=overwrite)
except (yaml.YAMLError, json.JSONDecodeError) as e:
logger.error(f"Failed to parse prompt file {prompt_file_path}: {e}")
raise
return self
def load_prompt_dict(self, prompt_dict: dict = None):
"""Merge a dictionary of prompt strings into the current context."""
def load_prompt_dict(
self,
prompt_dict: Optional[Dict[str, Any]] = None,
overwrite: bool = True
) -> "PromptHandler":
"""Merge a dictionary of prompt strings into the current context.
Only string values are stored as prompts. Non-string values are skipped.
Args:
prompt_dict: Dictionary mapping prompt names to prompt template strings.
overwrite: If True, allows overwriting existing prompts with warnings.
If False, skips existing prompts without overwriting.
Returns:
Self for method chaining.
"""
if not prompt_dict:
return self
for key, value in prompt_dict.items():
if isinstance(value, str):
if key in self:
logger.warning(f"Overwriting prompt key={key}, old_value={self[key]}, new_value={value}")
if not isinstance(value, str):
logger.debug(f"Skipping non-string prompt: key={key}, type={type(value)}")
continue
if key in self:
if overwrite:
logger.warning(
f"Overwriting prompt '{key}': "
f"old length={len(self[key])}, new length={len(value)}"
)
self[key] = value
else:
logger.debug(f"Adding new prompt key={key}, value={value}")
logger.debug(f"Skipping existing prompt: key={key}")
else:
logger.debug(f"Adding new prompt: key={key}, length={len(value)}")
self[key] = value
return self
def get_prompt(self, prompt_name: str):
"""Retrieve a prompt by name, automatically appending the language suffix if needed."""
key: str = prompt_name
if self.language and not key.endswith(self.language.strip()):
key += "_" + self.language.strip()
def get_prompt(self, prompt_name: str, fallback_to_base: bool = True) -> str:
"""Retrieve a prompt by name with automatic language suffix handling.
If a language is configured, this method will:
1. First try to find the prompt with language suffix (e.g., "greeting_en")
2. If not found and fallback_to_base is True, try the base name (e.g., "greeting")
3. Otherwise, raise PromptNotFoundError
Args:
prompt_name: Name of the prompt to retrieve.
fallback_to_base: If True and language-specific prompt not found,
fallback to prompt without language suffix.
Returns:
The prompt template string, stripped of leading/trailing whitespace.
Raises:
PromptNotFoundError: If the prompt is not found.
"""
# Try with language suffix first
if self.language and not prompt_name.endswith(f"_{self.language}"):
key_with_lang = f"{prompt_name}_{self.language}"
if key_with_lang in self:
return self[key_with_lang].strip()
assert key in self, f"prompt_name={key} not found."
return self[key].strip()
# Try base name
if prompt_name in self:
return self[prompt_name].strip()
def prompt_format(self, prompt_name: str, **kwargs) -> str:
"""Format a prompt by filtering flagged lines and filling template variables."""
prompt = self.get_prompt(prompt_name)
# Try fallback if enabled
if fallback_to_base and self.language:
# Check if prompt_name already has language suffix, try without it
if prompt_name.endswith(f"_{self.language}"):
base_name = prompt_name[: -(len(self.language) + 1)]
if base_name in self:
return self[base_name].strip()
# Separate boolean flags from string formatting arguments
flag_kwargs = {k: v for k, v in kwargs.items() if isinstance(v, bool)}
other_kwargs = {k: v for k, v in kwargs.items() if not isinstance(v, bool)}
# Not found, raise error with helpful message
available = list(self.keys())
raise PromptNotFoundError(prompt_name, available)
if flag_kwargs:
split_prompt = []
for line in prompt.strip().split("\n"):
hit = False
hit_flag = True
for key, flag in flag_kwargs.items():
if not line.startswith(f"[{key}]"):
continue
def has_prompt(self, prompt_name: str) -> bool:
"""Check if a prompt exists (with or without language suffix).
Args:
prompt_name: Name of the prompt to check.
Returns:
True if the prompt exists, False otherwise.
"""
try:
self.get_prompt(prompt_name)
return True
except PromptNotFoundError:
return False
hit = True
hit_flag = flag
# Remove the flag prefix from the line
line = line.strip(f"[{key}]")
def list_prompts(self, language_filter: Optional[str] = None) -> list[str]:
"""List all available prompt names.
Args:
language_filter: If provided, only return prompts for this language.
If None, return all prompts.
Returns:
List of prompt names.
"""
if language_filter is None:
return list(self.keys())
suffix = f"_{language_filter.strip()}"
return [key for key in self.keys() if key.endswith(suffix)]
@staticmethod
def _extract_format_fields(template: str) -> set[str]:
"""Extract all format field names from a template string.
Args:
template: Template string with {variable} placeholders.
Returns:
Set of field names used in the template.
"""
return {
field_name
for _, field_name, _, _ in Formatter().parse(template)
if field_name is not None
}
@staticmethod
def _filter_conditional_lines(prompt: str, flags: Dict[str, bool]) -> str:
"""Filter lines based on boolean flags.
Lines starting with [flag_name] are conditionally included based on
the value of flags[flag_name]. If True, the line is included (without
the flag marker). If False, the line is excluded.
Args:
prompt: The prompt text with conditional markers.
flags: Dictionary of flag names to boolean values.
Returns:
Filtered prompt text.
"""
filtered_lines = []
for line in prompt.split("\n"):
# Check each flag
matched_flag = None
for flag_name in flags:
marker = f"[{flag_name}]"
if line.startswith(marker):
matched_flag = flag_name
break
# Include line if no flag is present or if the flag evaluates to True
if not hit:
split_prompt.append(line)
elif hit_flag:
split_prompt.append(line)
if matched_flag is None:
# No flag marker, always include
filtered_lines.append(line)
elif flags[matched_flag]:
# Flag is True, include without marker
marker = f"[{matched_flag}]"
filtered_lines.append(line[len(marker):])
# else: Flag is False, skip this line
prompt = "\n".join(split_prompt)
return "\n".join(filtered_lines)
if other_kwargs:
# Apply standard Python string formatting
prompt = prompt.format(**other_kwargs)
def prompt_format(
self,
prompt_name: str,
validate: bool = True,
**kwargs
) -> str:
"""Format a prompt with conditional line filtering and variable substitution.
This method performs two-stage formatting:
1. Conditional line filtering: Lines marked with [flag] are included only
if the corresponding boolean kwarg is True.
2. Variable substitution: Template variables {var} are replaced with
provided values.
Args:
prompt_name: Name of the prompt to format.
validate: If True, check that all required template variables are provided.
**kwargs: Keyword arguments for formatting. Boolean values are treated as
conditional flags, other values are used for template substitution.
Returns:
Formatted prompt string.
Raises:
PromptNotFoundError: If the prompt is not found.
PromptFormattingError: If validation fails or formatting errors occur.
Examples:
>>> handler = PromptHandler()
>>> handler["test"] = "[debug]Debug: {info}\\nResult: {value}"
>>> handler.prompt_format("test", debug=False, info="test", value=42)
'Result: 42'
>>> handler.prompt_format("test", debug=True, info="test", value=42)
'Debug: test\\nResult: 42'
"""
# Get the prompt template
prompt = self.get_prompt(prompt_name)
return prompt
# Separate boolean flags from format variables
flag_kwargs = {k: v for k, v in kwargs.items() if isinstance(v, bool)}
format_kwargs = {k: v for k, v in kwargs.items() if not isinstance(v, bool)}
# Step 1: Filter conditional lines
if flag_kwargs:
prompt = self._filter_conditional_lines(prompt, flag_kwargs)
# Step 2: Validate required fields if requested
if validate:
required_fields = self._extract_format_fields(prompt)
missing_fields = required_fields - set(format_kwargs.keys())
if missing_fields:
raise PromptFormattingError(
f"Missing required format variables for prompt '{prompt_name}': "
f"{', '.join(sorted(missing_fields))}"
)
# Step 3: Format with variables
try:
if format_kwargs:
prompt = prompt.format(**format_kwargs)
except KeyError as e:
raise PromptFormattingError(
f"Format error in prompt '{prompt_name}': missing variable {e}"
) from e
except (ValueError, IndexError) as e:
raise PromptFormattingError(
f"Format error in prompt '{prompt_name}': {e}"
) from e
return prompt.strip()
def __repr__(self) -> str:
"""Return a string representation of the PromptHandler."""
return (
f"PromptHandler(language='{self.language}', "
f"num_prompts={len(self)})"
)

View file

@ -1,19 +1,143 @@
"""Module providing a registry class for managing class-to-name mappings via decorators."""
import inspect
from typing import Callable, TypeVar
from .base_context import BaseContext
from ..enumeration import RegistryEnum
from ...core_old.utils import singleton
T = TypeVar("T")
@singleton
class Registry(BaseContext):
"""A registry container that uses decorators to map and store class references."""
"""A singleton registry manager that maintains separate registries for different component types.
def register(self, name: str = "", add_cls: bool = True):
"""Return a decorator that registers a class under a specific name in the registry."""
This class serves as the central registry hub for the entire ReMe application, providing:
- Component registration for different types (LLMs, embeddings, vector stores, etc.)
- Convenient access methods for retrieving registered classes
- Decorator-based registration API
def decorator(cls):
if add_cls:
# Use provided name or default to the class name as the key
key = name or cls.__name__
self[key] = cls
return cls
The singleton pattern ensures only one instance exists throughout the application lifecycle,
accessible via the global `R` variable exported at the bottom of this module.
"""
return decorator
def __init__(self, **kwargs):
"""Initialize the registry manager with separate registries for each component type."""
super().__init__(**kwargs)
# Registry system: stores class definitions for different component types
self.registry_dict: dict[RegistryEnum, dict] = {
v: {} for v in RegistryEnum.__members__.values()
}
def register(self, name: str | type = "", register_type: RegistryEnum = None) -> Callable[[type[T]], type[T]] | type[T]:
"""Return a decorator to register a component within a specific registry category.
Can be used in multiple ways:
- @R.register_op() # with empty parentheses, uses class name
- @R.register_op # without parentheses, uses class name
- @R.register_op("custom_name") # with custom name
Args:
name: Either a string name for the class, or the class itself when used without parentheses
register_type: The type of registry (LLM, EMBEDDING_MODEL, VECTOR_STORE, etc.)
Returns:
Either a decorator function or the registered class itself
Example:
@R.register("my_llm", RegistryEnum.LLM)
class MyLLM(BaseLLM):
pass
"""
if inspect.isclass(name):
# Used without parentheses: @R.register_op
self.registry_dict[register_type][name.__name__] = name
return name
else:
# Used with parentheses: @R.register_op() or @R.register_op("name")
def decorator(cls):
key = name if isinstance(name, str) and name else cls.__name__
self.registry_dict[register_type][key] = cls
return cls
return decorator
def register_llm(self, name: str = ""):
"""Register a Large Language Model class."""
return self.register(name=name, register_type=RegistryEnum.LLM)
def register_embedding_model(self, name: str = ""):
"""Register an embedding model class."""
return self.register(name=name, register_type=RegistryEnum.EMBEDDING_MODEL)
def register_vector_store(self, name: str = ""):
"""Register a vector store implementation class."""
return self.register(name=name, register_type=RegistryEnum.VECTOR_STORE)
def register_op(self, name: str = ""):
"""Register an operation (Op) class."""
return self.register(name=name, register_type=RegistryEnum.OP)
def register_flow(self, name: str = ""):
"""Register a workflow or logic flow class."""
return self.register(name=name, register_type=RegistryEnum.FLOW)
def register_service(self, name: str = ""):
"""Register a backend service class."""
return self.register(name=name, register_type=RegistryEnum.SERVICE)
def register_token_counter(self, name: str = ""):
"""Register a token counting utility class."""
return self.register(name=name, register_type=RegistryEnum.TOKEN_COUNTER)
def get_model_class(self, name: str, register_type: RegistryEnum):
"""Retrieve a registered class by name from a specific registry category.
Args:
name: The registration name of the class
register_type: The type of registry to search in
Returns:
The registered class (not an instance, but the class itself)
Raises:
AssertionError: If the class is not found in the registry
"""
assert name in self.registry_dict[register_type], f"{name} not in registry_dict[{register_type}]"
return self.registry_dict[register_type][name]
def get_llm_class(self, name: str):
"""Get the LLM class registered under the given name."""
return self.get_model_class(name, RegistryEnum.LLM)
def get_embedding_model_class(self, name: str):
"""Get the embedding model class registered under the given name."""
return self.get_model_class(name, RegistryEnum.EMBEDDING_MODEL)
def get_vector_store_class(self, name: str):
"""Get the vector store class registered under the given name."""
return self.get_model_class(name, RegistryEnum.VECTOR_STORE)
def get_op_class(self, name: str):
"""Get the operation class registered under the given name."""
return self.get_model_class(name, RegistryEnum.OP)
def get_flow_class(self, name: str):
"""Get the flow class registered under the given name."""
return self.get_model_class(name, RegistryEnum.FLOW)
def get_service_class(self, name: str):
"""Get the service class registered under the given name."""
return self.get_model_class(name, RegistryEnum.SERVICE)
def get_token_counter_class(self, name: str):
"""Get the token counter class registered under the given name."""
return self.get_model_class(name, RegistryEnum.TOKEN_COUNTER)
# Export a global singleton instance for easy access across the application
# This is the primary way to access the registry throughout the codebase
R = Registry()

View file

@ -1,18 +1,38 @@
"""Defines the standard data types supported by JSON Schema."""
"""Defines the standard data types supported by JSON Schema.
This enum maps common JSON Schema primitive types to their corresponding
Python runtime types, and provides a convenient string representation
compatible with JSON Schema (`"string"`, `"number"`, etc.).
"""
from enum import Enum
class JsonSchemaEnum(Enum):
"""Enumeration of valid JSON Schema data types."""
"""Enumeration of valid JSON Schema data types.
The enum value is the corresponding Python type, while the string
representation (`str(...)`) is the canonical JSON Schema type name.
"""
# Textual data
STRING = str
# Numeric values, including integers and floats
NUMBER = float
# Integer-only numeric values
INTEGER = int
# JSON objects (key-value mappings)
OBJECT = dict
# Ordered JSON lists/arrays
ARRAY = list
# Boolean values: true / false
BOOLEAN = bool
def __str__(self) -> str:
"""Returns the string representation of the enum value."""
"""Return the lowercase JSON Schema type name for this enum member."""
return self.name.lower()

View file

@ -1,25 +1,33 @@
"""Memory type enumeration for the three-layer memory architecture."""
"""Defines the high-level categories of memory managed by ReMe.
This enumeration is used across the system to tag, route, and store different
kinds of memories (identity, personal context, procedures, tools, etc.).
"""
from enum import Enum
class MemoryType(str, Enum):
"""
Three-layer memory architecture for agent memory management.
"""Enumeration of memory categories used by the memory subsystem.
Layer 1 - High-level Abstraction Memory:
- IDENTITY: Self-cognition (identity, personality, current state)
- PERSONAL: Person-specific memory (preferences and context about specific individuals)
- PROCEDURAL: Procedural memory (how-to knowledge, e.g., 4 steps to write financial reports)
- TOOL: Tool memory (tool usage patterns, success rates, token consumption, latency)
Layer 2 - Summary Memory (Compressed): Summarized digest of raw message history
Layer 3 - History Memory (Raw): Raw message history
These types describe *what* a piece of memory is about, which guides
storage, retrieval, and summarization strategies.
"""
# Longterm, relatively stable attributes about the user (name, roles, etc.)
IDENTITY = "identity"
# User-specific preferences, habits, and evolving personal context
PERSONAL = "personal"
# Howto knowledge, workflows, and stepbystep instructions
PROCEDURAL = "procedural"
# Information learned about tools, APIs, and their usage patterns
TOOL = "tool"
# Condensed representation of larger memory collections
SUMMARY = "summary"
# Raw chronological interaction history, typically before summarization
HISTORY = "history"

View file

@ -0,0 +1,17 @@
"""Core module for ReMe AI framework."""
# pylint: disable=wrong-import-position
# flake8: noqa: F401
from . import config
from . import context
from . import embedding
from . import enumeration
from . import flow
from . import llm
from . import op
from . import schema
from . import service
from . import token_counter
from . import utils
from . import vector_store

View file

@ -0,0 +1,16 @@
"""context"""
from .base_context import BaseContext
from .prompt_handler import PromptHandler
from .registry import Registry
from .runtime_context import RuntimeContext
from .service_context import ServiceContext, C
__all__ = [
"BaseContext",
"PromptHandler",
"Registry",
"RuntimeContext",
"ServiceContext",
"C",
]

View file

@ -0,0 +1,41 @@
"""Module providing a dictionary subclass with attribute-style access and pickling support."""
from typing import Generic, TypeVar
_KT = TypeVar("_KT")
_VT = TypeVar("_VT")
class BaseContext(dict, Generic[_KT, _VT]):
"""A dictionary subclass that enables accessing and modifying keys as attributes."""
def __getattr__(self, name: str) -> _VT:
"""Retrieve a dictionary item as an attribute."""
try:
return self[name]
except KeyError as e:
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") from e
def __setattr__(self, name: str, value: _VT) -> None:
"""Assign a value to a dictionary item using attribute syntax."""
self[name] = value
def __delattr__(self, name: str) -> None:
"""Remove a dictionary item using attribute syntax."""
try:
# Delete item from dict via key
del self[name]
except KeyError as e:
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") from e
def __getstate__(self) -> dict:
"""Return the dictionary representation for pickling."""
return dict(self)
def __setstate__(self, state: dict) -> None:
"""Restore the dictionary state from a pickled object."""
self.update(state)
def __reduce__(self):
"""Define the reconstruction logic for pickling processes."""
return self.__class__, (), self.__getstate__()

View file

@ -0,0 +1,95 @@
"""Module for managing and formatting prompt templates from files or dictionaries."""
from pathlib import Path
import yaml
from loguru import logger
from .base_context import BaseContext
from .service_context import C
class PromptHandler(BaseContext):
"""A context-aware handler for loading, retrieving, and formatting prompt templates."""
def __init__(self, language: str = "", **kwargs):
"""Initialize the handler with a specific language and optional context data."""
super().__init__(**kwargs)
self.language: str = language or C.language
def load_prompt_by_file(self, prompt_file_path: Path | str = None):
"""Load prompt configurations from a YAML file into the context."""
if prompt_file_path is None:
return self
if isinstance(prompt_file_path, str):
prompt_file_path = Path(prompt_file_path)
if not prompt_file_path.exists():
return self
with prompt_file_path.open(encoding="utf-8") as f:
# Load YAML content using the full loader
prompt_dict = yaml.load(f, yaml.FullLoader)
self.load_prompt_dict(prompt_dict)
return self
def load_prompt_dict(self, prompt_dict: dict = None):
"""Merge a dictionary of prompt strings into the current context."""
if not prompt_dict:
return self
for key, value in prompt_dict.items():
if isinstance(value, str):
if key in self:
logger.warning(f"Overwriting prompt key={key}, old_value={self[key]}, new_value={value}")
else:
logger.debug(f"Adding new prompt key={key}, value={value}")
self[key] = value
return self
def get_prompt(self, prompt_name: str):
"""Retrieve a prompt by name, automatically appending the language suffix if needed."""
key: str = prompt_name
if self.language and not key.endswith(self.language.strip()):
key += "_" + self.language.strip()
assert key in self, f"prompt_name={key} not found."
return self[key].strip()
def prompt_format(self, prompt_name: str, **kwargs) -> str:
"""Format a prompt by filtering flagged lines and filling template variables."""
prompt = self.get_prompt(prompt_name)
# Separate boolean flags from string formatting arguments
flag_kwargs = {k: v for k, v in kwargs.items() if isinstance(v, bool)}
other_kwargs = {k: v for k, v in kwargs.items() if not isinstance(v, bool)}
if flag_kwargs:
split_prompt = []
for line in prompt.strip().split("\n"):
hit = False
hit_flag = True
for key, flag in flag_kwargs.items():
if not line.startswith(f"[{key}]"):
continue
hit = True
hit_flag = flag
# Remove the flag prefix from the line
line = line.strip(f"[{key}]")
break
# Include line if no flag is present or if the flag evaluates to True
if not hit:
split_prompt.append(line)
elif hit_flag:
split_prompt.append(line)
prompt = "\n".join(split_prompt)
if other_kwargs:
# Apply standard Python string formatting
prompt = prompt.format(**other_kwargs)
return prompt

View file

@ -0,0 +1,46 @@
"""Module providing a registry class for managing class-to-name mappings via decorators."""
import inspect
from typing import Callable, TypeVar
from .base_context import BaseContext
T = TypeVar('T')
class Registry(BaseContext):
"""A registry container that uses decorators to map and store class references."""
def register(self, name: str | type = "", add_cls: bool = True) -> Callable[[type[T]], type[T]] | type[T]:
"""Return a decorator that registers a class under a specific name in the registry.
Can be used in three ways:
- @C.register_op() # with empty parentheses, uses class name
- @C.register_op # without parentheses, uses class name
- @C.register_op("custom_name") # with custom name
Args:
name: Either a string name for the class, or the class itself when used without parentheses
add_cls: Whether to actually add the class to the registry
Returns:
Either a decorator function or the registered class itself
"""
def decorator(cls):
if add_cls:
# Use provided name or default to the class name as the key
key = name if isinstance(name, str) and name else cls.__name__
self[key] = cls
return cls
# If used without parentheses: @C.register_op
if inspect.isclass(name):
cls = name
# Register with class name as key
if add_cls:
self[cls.__name__] = cls
return cls
# If used with parentheses: @C.register_op() or @C.register_op("name")
return decorator

View file

@ -0,0 +1,79 @@
"""Runtime context for managing response states and asynchronous data streaming."""
import asyncio
from .base_context import BaseContext
from ..enumeration import ChunkEnum
from ..schema import Response, StreamChunk
class RuntimeContext(BaseContext):
"""Context for execution state, response metadata, and stream queues."""
def __init__(
self,
response: Response | None = None,
stream_queue: asyncio.Queue | None = None,
**kwargs,
):
"""Initialize the context with optional response and queue."""
super().__init__(**kwargs)
self.response = response or Response()
self.stream_queue = stream_queue
@classmethod
def from_context(cls, context: "RuntimeContext | None" = None, **kwargs) -> "RuntimeContext":
"""Create a new context from an existing instance or keywords."""
if context is None:
return cls(**kwargs)
context.update(kwargs)
return context
async def _enqueue(self, chunk: StreamChunk) -> None:
"""Internal helper to put a chunk into the queue if it exists."""
if self.stream_queue:
await self.stream_queue.put(chunk)
async def add_stream_string(self, chunk: str, chunk_type: ChunkEnum) -> "RuntimeContext":
"""Enqueue a stream chunk from a raw string and type."""
await self._enqueue(StreamChunk(chunk_type=chunk_type, chunk=chunk))
return self
async def add_stream_chunk(self, stream_chunk: StreamChunk) -> "RuntimeContext":
"""Enqueue an existing stream chunk."""
await self._enqueue(stream_chunk)
return self
async def add_stream_done(self) -> "RuntimeContext":
"""Enqueue a termination chunk to signal the end of the stream."""
await self._enqueue(StreamChunk(chunk_type=ChunkEnum.DONE, chunk="", done=True))
return self
def add_response_error(self, e: Exception) -> "RuntimeContext":
"""Record an exception into the response object."""
self.response.success = False
self.response.answer = str(e)
return self
def apply_mapping(self, mapping: dict[str, str]) -> "RuntimeContext":
"""Copy internal values based on a source-to-target key map."""
if not mapping:
return self
for source, target in mapping.items():
if source in self:
self[target] = self[source]
return self
def validate_required_keys(self, required_keys: dict[str, bool], context_name: str = "context") -> "RuntimeContext":
"""Ensure all required keys are present in the context.
Args:
required_keys: Dictionary mapping key names to boolean indicating if required
context_name: Name of the context for error messages (e.g., operator name)
"""
for key, is_required in required_keys.items():
if is_required and key not in self:
raise ValueError(f"{context_name}: missing required input '{key}'")
return self

View file

@ -0,0 +1,17 @@
"""enumeration"""
from .chunk_enum import ChunkEnum
from .http_enum import HttpEnum
from .json_schema_enum import JsonSchemaEnum
from .memory_type import MemoryType
from .registry_enum import RegistryEnum
from .role import Role
__all__ = [
"ChunkEnum",
"HttpEnum",
"JsonSchemaEnum",
"MemoryType",
"RegistryEnum",
"Role",
]

View file

@ -0,0 +1,25 @@
"""Defines the types of data chunks used in streaming responses."""
from enum import Enum
class ChunkEnum(str, Enum):
"""Enumeration of possible chunk categories for stream processing."""
# Internal reasoning or chain-of-thought process
THINK = "think"
# The final generated response content
ANSWER = "answer"
# Metadata or calls related to external tools
TOOL = "tool"
# Resource consumption and token usage statistics
USAGE = "usage"
# Error messages or exception details
ERROR = "error"
# Final signal indicating the completion of the stream
DONE = "done"

View file

@ -0,0 +1,22 @@
"""Provides a collection of standard HTTP request methods."""
from enum import Enum
class HttpEnum(str, Enum):
"""Enumeration of supported HTTP methods for network requests."""
# Retrieves data from a specified resource
GET = "get"
# Submits data to be processed to a specified resource
POST = "post"
# Identical to GET but only retrieves the response headers
HEAD = "head"
# Uploads or replaces the representation of a target resource
PUT = "put"
# Deletes the specified resource from the server
DELETE = "delete"

View file

@ -0,0 +1,18 @@
"""Defines the standard data types supported by JSON Schema."""
from enum import Enum
class JsonSchemaEnum(Enum):
"""Enumeration of valid JSON Schema data types."""
STRING = str
NUMBER = float
INTEGER = int
OBJECT = dict
ARRAY = list
BOOLEAN = bool
def __str__(self) -> str:
"""Returns the string representation of the enum value."""
return self.name.lower()

View file

@ -0,0 +1,25 @@
"""Memory type enumeration for the three-layer memory architecture."""
from enum import Enum
class MemoryType(str, Enum):
"""
Three-layer memory architecture for agent memory management.
Layer 1 - High-level Abstraction Memory:
- IDENTITY: Self-cognition (identity, personality, current state)
- PERSONAL: Person-specific memory (preferences and context about specific individuals)
- PROCEDURAL: Procedural memory (how-to knowledge, e.g., 4 steps to write financial reports)
- TOOL: Tool memory (tool usage patterns, success rates, token consumption, latency)
Layer 2 - Summary Memory (Compressed): Summarized digest of raw message history
Layer 3 - History Memory (Raw): Raw message history
"""
IDENTITY = "identity"
PERSONAL = "personal"
PROCEDURAL = "procedural"
TOOL = "tool"
SUMMARY = "summary"
HISTORY = "history"

View file

@ -0,0 +1,28 @@
"""Defines the registry categories for core components of the system."""
from enum import Enum
class RegistryEnum(str, Enum):
"""Enumeration of component types registered within the application lifecycle."""
# Large Language Model interfaces
LLM = "llm"
# Models used for generating vector embeddings
EMBEDDING_MODEL = "embedding_model"
# Databases or storage systems for vector search
VECTOR_STORE = "vector_store"
# Atomic operations or functional units
OP = "op"
# Orchestrated sequences of operations or workflows
FLOW = "flow"
# External APIs or shared internal services
SERVICE = "service"
# Utilities for tracking and limiting token consumption
TOKEN_COUNTER = "token_counter"

View file

@ -0,0 +1,19 @@
"""Defines the participant roles in a chat completion sequence."""
from enum import Enum
class Role(str, Enum):
"""Enumeration of standard personas involved in a conversation flow."""
# High-level instructions to guide the model's behavior
SYSTEM = "system"
# Input or queries provided by the human user
USER = "user"
# Responses or messages generated by the AI model
ASSISTANT = "assistant"
# Output or results returned from external tool executions
TOOL = "tool"

View file

@ -19,7 +19,7 @@ class BaseLLM(ABC):
def __init__(self, model_name: str, max_retries: int = 10, raise_exception: bool = False, request_interval: float = 0.0, **kwargs):
"""Initialize the LLM client with model configurations and retry policies.
Args:
model_name: The name of the model to use
max_retries: Maximum number of retry attempts on failure
@ -32,7 +32,7 @@ class BaseLLM(ABC):
self.raise_exception: bool = raise_exception
self.request_interval: float = request_interval
self.kwargs: dict = kwargs
# Request rate control for async operations
self._last_request_time: float = 0.0
self._request_lock: asyncio.Lock = asyncio.Lock()
@ -87,7 +87,7 @@ class BaseLLM(ABC):
**kwargs,
) -> dict:
"""Construct provider-specific parameters for streaming API requests.
Args:
messages: List of conversation messages
tools: Optional list of tool calls
@ -122,7 +122,7 @@ class BaseLLM(ABC):
**kwargs,
) -> AsyncGenerator[StreamChunk, None]:
"""Public async interface for streaming chat completions with retries.
Args:
messages: List of conversation messages
tools: Optional list of tool calls
@ -138,10 +138,10 @@ class BaseLLM(ABC):
sleep_time = self.request_interval - elapsed
await asyncio.sleep(sleep_time)
self._last_request_time = time.time()
async for chunk in self._stream_chat_impl(messages, tools, model_name, **kwargs):
yield chunk
async def _stream_chat_impl(
self,
messages: list[Message],
@ -178,7 +178,7 @@ class BaseLLM(ABC):
**kwargs,
) -> Generator[StreamChunk, None, None]:
"""Public synchronous interface for streaming chat completions with retries.
Args:
messages: List of conversation messages
tools: Optional list of tool calls
@ -213,7 +213,7 @@ class BaseLLM(ABC):
**kwargs,
) -> Message:
"""Internal async method to aggregate a full response by consuming the stream.
Args:
messages: List of conversation messages
tools: Optional list of tool calls
@ -281,7 +281,7 @@ class BaseLLM(ABC):
**kwargs,
) -> Message:
"""Internal synchronous method to aggregate a full response by consuming the stream.
Args:
messages: List of conversation messages
tools: Optional list of tool calls
@ -351,7 +351,7 @@ class BaseLLM(ABC):
**kwargs,
) -> Message | Any:
"""Perform an async chat completion with integrated retries and error handling.
Args:
messages: List of conversation messages
tools: Optional list of tool calls
@ -370,9 +370,9 @@ class BaseLLM(ABC):
sleep_time = self.request_interval - elapsed
await asyncio.sleep(sleep_time)
self._last_request_time = time.time()
return await self._chat_impl(messages, tools, enable_stream_print, callback_fn, default_value, model_name, **kwargs)
async def _chat_impl(
self,
messages: list[Message],
@ -386,7 +386,7 @@ class BaseLLM(ABC):
"""Internal implementation of chat with retry and error handling logic."""
# Use the provided model_name or fall back to self.model_name
effective_model = model_name if model_name is not None else self.model_name
for i in range(self.max_retries):
try:
result = await self._chat(
@ -407,7 +407,7 @@ class BaseLLM(ABC):
"exceeded your current quota" in error_message.lower() or
"insufficient_quota" in error_message.lower()
)
if is_inappropriate_content:
logger.error(f"chat with model={effective_model} detected inappropriate content error")
logger.error("=" * 80)
@ -424,12 +424,12 @@ class BaseLLM(ABC):
logger.error("=" * 80)
# Return empty Message immediately without retrying
return Message(role=Role.ASSISTANT, content="")
if is_rate_limit_error:
logger.warning(f"chat with model={effective_model} hit rate limit, sleeping for 60s before retry (attempt {i + 1}/{self.max_retries})")
await asyncio.sleep(60)
continue
logger.exception(f"chat with model={effective_model} encounter error with e={e.args}")
if i == self.max_retries - 1:
@ -451,7 +451,7 @@ class BaseLLM(ABC):
**kwargs,
) -> Message | Any:
"""Perform a synchronous chat completion with integrated retries and error handling.
Args:
messages: List of conversation messages
tools: Optional list of tool calls
@ -463,7 +463,7 @@ class BaseLLM(ABC):
"""
# Use the provided model_name or fall back to self.model_name
effective_model = model_name if model_name is not None else self.model_name
for i in range(self.max_retries):
try:
result = self._chat_sync(
@ -484,7 +484,7 @@ class BaseLLM(ABC):
"exceeded your current quota" in error_message.lower() or
"insufficient_quota" in error_message.lower()
)
if is_inappropriate_content:
logger.error(f"chat sync with model={effective_model} detected inappropriate content error")
logger.error("=" * 80)
@ -501,12 +501,12 @@ class BaseLLM(ABC):
logger.error("=" * 80)
# Return empty Message immediately without retrying
return Message(role=Role.ASSISTANT, content="")
if is_rate_limit_error:
logger.warning(f"chat sync with model={effective_model} hit rate limit, sleeping for 60s before retry (attempt {i + 1}/{self.max_retries})")
time.sleep(60)
continue
logger.exception(f"chat sync with model={effective_model} encounter error with e={e.args}")
if i == self.max_retries - 1:

View file

@ -40,7 +40,7 @@ class LiteLLM(BaseLLM):
**kwargs,
) -> dict:
"""Construct and log the parameters dictionary for LiteLLM API calls.
Args:
messages: List of conversation messages
tools: Optional list of tool calls
@ -50,7 +50,7 @@ class LiteLLM(BaseLLM):
"""
# Use the provided model_name or fall back to self.model_name
effective_model = model_name if model_name is not None else self.model_name
# Construct the API parameters by merging multiple sources
llm_kwargs = {
"model": effective_model,

View file

@ -45,7 +45,7 @@ class OpenAILLM(BaseLLM):
**kwargs,
) -> dict:
"""Construct the parameter dictionary for the OpenAI Chat Completions API call.
Args:
messages: List of conversation messages
tools: Optional list of tool calls
@ -55,7 +55,7 @@ class OpenAILLM(BaseLLM):
"""
# Use the provided model_name or fall back to self.model_name
effective_model = model_name if model_name is not None else self.model_name
# Construct the API parameters by merging multiple sources
llm_kwargs = {
"model": effective_model,

View file

@ -1,14 +1,14 @@
"""ReMe classes for simplified configuration and execution."""
from .core.application import Application
from .core.config import ReMeConfigParser
from .core.context import C
from .core.embedding import BaseEmbeddingModel
from .core.enumeration import Role
from .core.llm import BaseLLM
from .core.schema import Message
from .core.utils import singleton
from .core.vector_store import BaseVectorStore
from .core_old.application import Application
from .core_old.config import ReMeConfigParser
from .core_old.context import C
from .core_old.embedding import BaseEmbeddingModel
from .core_old.enumeration import Role
from .core_old.llm import BaseLLM
from .core_old.schema import Message
from .core_old.utils import singleton
from .core_old.vector_store import BaseVectorStore
from .mem_agent.retriever import ReMeRetriever
from .mem_agent.retriever_v2 import ReMeRetrieverV2
from .mem_agent.summarizer import ReMeSummarizer, PersonalSummarizer
@ -177,7 +177,6 @@ class ReMe(Application):
except Exception as e:
print(f"Warning: reme_summarizer.call failed: {e}")
return []
else:
raise NotImplementedError
@ -228,18 +227,17 @@ class ReMe(Application):
except Exception as e:
print(f"Warning: reme_retriever.call failed: {e}")
return "error, not retrieved"
else:
raise NotImplementedError
async def summary_v2(
self,
messages: list[dict],
description: str = "",
user_id: str = "",
assistant_id: str = "",
**kwargs,
self,
messages: list[dict],
description: str = "",
user_id: str = "",
assistant_id: str = "",
**kwargs,
):
"""Summarizes messages using V2 workflow with simplified tools."""
@ -293,14 +291,14 @@ class ReMe(Application):
raise NotImplementedError
async def retrieve_v2(
self,
query: str = "",
messages: list[dict] | None = None,
description: str = "",
user_id: str = "",
assistant_id: str = "",
top_k: int = 20,
**kwargs,
self,
query: str = "",
messages: list[dict] | None = None,
description: str = "",
user_id: str = "",
assistant_id: str = "",
top_k: int = 20,
**kwargs,
):
"""Retrieves relevant memories using V2 workflow with autonomous retrieval."""
@ -342,12 +340,12 @@ class ReMe(Application):
raise NotImplementedError
async def summary_v3(
self,
messages: list[dict],
description: str = "",
user_id: str = "",
assistant_id: str = "",
**kwargs,
self,
messages: list[dict],
description: str = "",
user_id: str = "",
assistant_id: str = "",
**kwargs,
):
"""Summarizes messages using V3 workflow with user profile management."""
@ -384,14 +382,14 @@ class ReMe(Application):
raise NotImplementedError
async def retrieve_v3(
self,
query: str = "",
messages: list[dict] | None = None,
description: str = "",
user_id: str = "",
assistant_id: str = "",
top_k: int = 20,
**kwargs,
self,
query: str = "",
messages: list[dict] | None = None,
description: str = "",
user_id: str = "",
assistant_id: str = "",
top_k: int = 20,
**kwargs,
):
"""Retrieves relevant memories using V3 workflow with user profile support."""
@ -425,13 +423,13 @@ class ReMe(Application):
raise NotImplementedError
async def summary_v4(
self,
messages: list[dict],
description: str = "",
user_id: str = "",
assistant_id: str = "",
enable_thinking_params: bool = False,
**kwargs,
self,
messages: list[dict],
description: str = "",
user_id: str = "",
assistant_id: str = "",
enable_thinking_params: bool = False,
**kwargs,
):
"""Summarizes messages using V4 workflow with simplified memory management."""
@ -464,15 +462,15 @@ class ReMe(Application):
raise NotImplementedError
async def retrieve_v4(
self,
query: str = "",
messages: list[dict] | None = None,
description: str = "",
user_id: str = "",
assistant_id: str = "",
top_k: int = 20,
enable_thinking_params: bool = False,
**kwargs,
self,
query: str = "",
messages: list[dict] | None = None,
description: str = "",
user_id: str = "",
assistant_id: str = "",
top_k: int = 20,
enable_thinking_params: bool = False,
**kwargs,
):
"""Retrieves relevant memories using V4 workflow with enhanced retrieval."""

View file

@ -177,7 +177,7 @@ class ToolCall(BaseModel):
return True
except Exception:
return False
def sanitize_and_check_argument(self) -> bool:
"""
Attempt to sanitize and validate arguments JSON.
@ -188,17 +188,17 @@ class ToolCall(BaseModel):
"""
if not self.arguments or not self.arguments.strip():
return False
try:
# First try parsing as-is
_ = json.loads(self.arguments)
return True
except json.JSONDecodeError:
pass
# Try to fix common issues
sanitized = self.arguments.strip()
# Remove trailing extra brackets/braces
# Pattern: if it ends with multiple closing chars, try removing extras
while len(sanitized) > 1:
@ -212,7 +212,7 @@ class ToolCall(BaseModel):
sanitized = sanitized[:-1].rstrip()
else:
break
return False
def simple_output_dump(self) -> dict:

Some files were not shown because too many files have changed in this diff Show more