diff --git a/benchmark/locomo/README.md b/benchmark/locomo/README.md index cbb1bf460..fd42a177d 100644 --- a/benchmark/locomo/README.md +++ b/benchmark/locomo/README.md @@ -10,9 +10,10 @@ benchmark/locomo/ │ ├── run_eval.py # 运行 QA 评估 │ ├── judge.py # LLM 裁判打分 │ ├── import_to_ov.py # 导入数据到 OpenViking -│ ├── stat_judge_result.py # 统计评分结果 -│ ├── run_full_eval.sh # 一键运行完整评测流程 -│ ├── test_data/ # 测试数据目录 +│ ├── import_and_eval_one.sh # 单题/批量测试脚本 +│ ├── stat_judge_result.py # 统计评分结果 +│ ├── run_full_eval.sh # 一键运行完整评测流程 +│ ├── data/ # 测试数据目录 │ └── result/ # 评测结果目录 └── openclaw/ # OpenClaw 评测脚本 └── eval.py # OpenClaw 评估脚本 @@ -28,11 +29,33 @@ benchmark/locomo/ ```bash cd benchmark/locomo/vikingbot -bash run_full_eval.sh +bash run_full_eval.sh # 完整流程 +bash run_full_eval.sh --skip-import # 跳过导入,仅评测 ``` 该脚本会依次执行以下四个步骤: +### 单题/批量测试 + +使用 `import_and_eval_one.sh` 可以快速测试单个问题或批量测试某个 sample: + +```bash +cd benchmark/locomo/vikingbot +``` + +**单题测试:** +```bash +./import_and_eval_one.sh 0 2 # sample 索引 0, question 2 +./import_and_eval_one.sh conv-26 2 # sample_id conv-26, question 2 +./import_and_eval_one.sh conv-26 2 --skip-import # 跳过导入 +``` + +**批量测试单个 sample:** +```bash +./import_and_eval_one.sh conv-26 # conv-26 所有问题 +./import_and_eval_one.sh conv-26 --skip-import +``` + ### 分步使用说明 #### 步骤 1: 导入对话数据 @@ -44,7 +67,7 @@ python import_to_ov.py --input <数据文件路径> [选项] ``` **参数说明:** -- `--input`: 输入文件路径(JSON 或 TXT 格式),默认 `./test_data/locomo10.json` +- `--input`: 输入文件路径(JSON 或 TXT 格式),默认 `./data/locomo10.json` - `--sample`: 指定样本索引(0-based),默认处理所有样本 - `--sessions`: 指定会话范围,例如 `1-4` 或 `3`,默认所有会话 - `--parallel`: 并发导入数,默认 5 @@ -55,10 +78,10 @@ python import_to_ov.py --input <数据文件路径> [选项] **示例:** ```bash # 导入第一个样本的 1-4 会话 -python import_to_ov.py --input ./test_data/locomo10.json --sample 0 --sessions 1-4 +python import_to_ov.py --input ./data/locomo10.json --sample 0 --sessions 1-4 # 强制重新导入所有数据 -python import_to_ov.py --input ./test_data/locomo10.json --force-ingest +python import_to_ov.py --input ./data/locomo10.json --force-ingest ``` #### 步骤 2: 运行 QA 评估 @@ -70,7 +93,7 @@ python run_eval.py <输入数据> [选项] ``` **参数说明:** -- `input`: 输入 JSON/CSV 文件路径,默认 `./test_data/locomo10.json` +- `input`: 输入 JSON/CSV 文件路径,默认 `./data/locomo10.json` - `--output`: 输出 CSV 文件路径,默认 `./result/locomo_qa_result.csv` - `--sample`: 指定样本索引 - `--count`: 运行的 QA 问题数量,默认全部 @@ -82,7 +105,7 @@ python run_eval.py <输入数据> [选项] python run_eval.py # 指定输入输出文件,使用 20 线程 -python run_eval.py ./test_data/locomo_qa_1528.csv --output ./result/my_result.csv --threads 20 +python run_eval.py ./data/locomo_qa_1528.csv --output ./result/my_result.csv --threads 20 ``` #### 步骤 3: LLM 裁判打分 diff --git a/benchmark/locomo/vikingbot/import_and_eval_one.sh b/benchmark/locomo/vikingbot/import_and_eval_one.sh new file mode 100755 index 000000000..3289fcb14 --- /dev/null +++ b/benchmark/locomo/vikingbot/import_and_eval_one.sh @@ -0,0 +1,217 @@ +#!/bin/bash +# 单题/批量测试脚本:导入对话 + 提问验证 +# +# Usage: +# ./import_and_eval_one.sh 0 2 # sample 0, question 2 (单题) +# ./import_and_eval_one.sh conv-26 2 # sample_id conv-26, question 2 (单题) +# ./import_and_eval_one.sh conv-26 # sample_id conv-26, 所有问题 (批量) +# ./import_and_eval_one.sh conv-26 2 --skip-import # 跳过导入,直接评测 +# ./import_and_eval_one.sh conv-26 --skip-import # 跳过导入,批量评测 + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SKIP_IMPORT=false + +# 解析参数 +for arg in "$@"; do + if [ "$arg" = "--skip-import" ]; then + SKIP_IMPORT=true + fi +done + +# 过滤掉 --skip-import 获取实际参数 +ARGS=() +for arg in "$@"; do + if [ "$arg" != "--skip-import" ]; then + ARGS+=("$arg") + fi +done + +SAMPLE=${ARGS[0]} +QUESTION_INDEX=${ARGS[1]} +INPUT_FILE="$SCRIPT_DIR/../data/locomo10.json" + +if [ -z "$SAMPLE" ]; then + echo "Usage: $0 [question_index] [--skip-import]" + echo " sample_index: 数字索引 (0,1,2...) 或 sample_id (conv-26)" + echo " question_index: 问题索引 (可选),不传则测试该 sample 的所有问题" + echo " --skip-import: 跳过导入步骤,直接使用已导入的数据进行评测" + exit 1 +fi + +# 判断是数字还是 sample_id +if [[ "$SAMPLE" =~ ^-?[0-9]+$ ]]; then + SAMPLE_INDEX=$SAMPLE + SAMPLE_ID_FOR_CMD=$SAMPLE_INDEX + echo "Using sample index: $SAMPLE_INDEX" +else + # 通过 sample_id 查找索引 + SAMPLE_INDEX=$(python3 -c " +import json +data = json.load(open('$INPUT_FILE')) +for i, s in enumerate(data): + if s.get('sample_id') == '$SAMPLE': + print(i) + break +else: + print('NOT_FOUND') +") + if [ "$SAMPLE_INDEX" = "NOT_FOUND" ]; then + echo "Error: sample_id '$SAMPLE' not found" + exit 1 + fi + SAMPLE_ID_FOR_CMD=$SAMPLE + echo "Using sample_id: $SAMPLE (index: $SAMPLE_INDEX)" +fi + +# 判断是单题模式还是批量模式 +if [ -n "$QUESTION_INDEX" ]; then + # ========== 单题模式 ========== + echo "=== 单题模式: sample $SAMPLE, question $QUESTION_INDEX ===" + + # 导入对话(只导入 question 对应的 session) + if [ "$SKIP_IMPORT" = "true" ]; then + echo "[1/3] Skipping import (--skip-import)" + else + echo "[1/3] Importing sample $SAMPLE_INDEX, question $QUESTION_INDEX..." + python benchmark/locomo/vikingbot/import_to_ov.py \ + --input "$INPUT_FILE" \ + --sample "$SAMPLE_INDEX" \ + --question-index "$QUESTION_INDEX" \ + --force-ingest + + echo "Waiting for data processing..." + sleep 3 + fi + + # 运行评测 + if [ "$SKIP_IMPORT" = "true" ]; then + echo "[1/2] Running evaluation (skip-import mode)..." + else + echo "[2/3] Running evaluation..." + fi + if [[ "$SAMPLE" =~ ^-?[0-9]+$ ]]; then + # 数字索引用默认输出文件 + OUTPUT_FILE=./result/locomo_qa_result.csv + python benchmark/locomo/vikingbot/run_eval.py \ + "$INPUT_FILE" \ + --sample "$SAMPLE_ID_FOR_CMD" \ + --question-index "$QUESTION_INDEX" \ + --count 1 + else + # sample_id 模式直接更新批量结果文件 + OUTPUT_FILE=./result/locomo_${SAMPLE}_result.csv + python benchmark/locomo/vikingbot/run_eval.py \ + "$INPUT_FILE" \ + --sample "$SAMPLE_ID_FOR_CMD" \ + --question-index "$QUESTION_INDEX" \ + --count 1 \ + --output "$OUTPUT_FILE" \ + --update-mode + fi + + # 运行 Judge 评分 + if [ "$SKIP_IMPORT" = "true" ]; then + echo "[2/2] Running judge..." + else + echo "[3/3] Running judge..." + fi + python benchmark/locomo/vikingbot/judge.py --input "$OUTPUT_FILE" --parallel 1 + + # 输出结果 + echo "" + echo "=== 评测结果 ===" + python3 -c " +import csv +import json + +question_index = $QUESTION_INDEX + +with open('$OUTPUT_FILE') as f: + reader = csv.DictReader(f) + rows = list(reader) + +# 找到指定 question_index 的结果 +row = None +for r in rows: + if int(r.get('question_index', -1)) == question_index: + row = r + break + +if row is None: + # 没找到则用最后一条 + row = rows[-1] + +# 解析 evidence_text +evidence_text = json.loads(row.get('evidence_text', '[]')) +evidence_str = '\\n'.join(evidence_text) if evidence_text else '' + +print(f\"问题: {row['question']}\") +print(f\"期望答案: {row['answer']}\") +print(f\"模型回答: {row['response']}\") +print(f\"证据原文:\\n{evidence_str}\") +print(f\"结果: {row.get('result', 'N/A')}\") +print(f\"原因: {row.get('reasoning', 'N/A')}\") +" + +else + # ========== 批量模式 ========== + echo "=== 批量模式: sample $SAMPLE, 所有问题 ===" + + # 获取该 sample 的问题数量 + QUESTION_COUNT=$(python3 -c " +import json +data = json.load(open('$INPUT_FILE')) +sample = data[$SAMPLE_INDEX] +print(len(sample.get('qa', []))) +") + echo "Found $QUESTION_COUNT questions for sample $SAMPLE" + + # 导入所有 sessions + if [ "$SKIP_IMPORT" = "true" ]; then + echo "[1/4] Skipping import (--skip-import)" + else + echo "[1/4] Importing all sessions for sample $SAMPLE_INDEX..." + python benchmark/locomo/vikingbot/import_to_ov.py \ + --input "$INPUT_FILE" \ + --sample "$SAMPLE_INDEX" \ + --force-ingest + + echo "Waiting for data processing..." + sleep 10 + fi + + # 运行评测(所有问题) + if [ "$SKIP_IMPORT" = "true" ]; then + echo "[1/3] Running evaluation for all questions (skip-import mode)..." + else + echo "[2/4] Running evaluation for all questions..." + fi + OUTPUT_FILE=./result/locomo_${SAMPLE}_result.csv + python benchmark/locomo/vikingbot/run_eval.py \ + "$INPUT_FILE" \ + --sample "$SAMPLE_ID_FOR_CMD" \ + --output "$OUTPUT_FILE" \ + --threads 5 + + # 运行 Judge 评分 + if [ "$SKIP_IMPORT" = "true" ]; then + echo "[2/3] Running judge..." + else + echo "[3/4] Running judge..." + fi + python benchmark/locomo/vikingbot/judge.py --input "$OUTPUT_FILE" --parallel 5 + + # 输出统计结果 + if [ "$SKIP_IMPORT" = "true" ]; then + echo "[3/3] Calculating statistics..." + else + echo "[4/4] Calculating statistics..." + fi + python benchmark/locomo/vikingbot/stat_judge_result.py --input "$OUTPUT_FILE" + + echo "" + echo "=== 批量评测完成 ===" + echo "结果文件: $OUTPUT_FILE" +fi \ No newline at end of file diff --git a/benchmark/locomo/vikingbot/import_to_ov.py b/benchmark/locomo/vikingbot/import_to_ov.py index 9d68ad520..509f93b2a 100644 --- a/benchmark/locomo/vikingbot/import_to_ov.py +++ b/benchmark/locomo/vikingbot/import_to_ov.py @@ -68,7 +68,7 @@ def load_locomo_data( if sample_index is not None: if sample_index < 0 or sample_index >= len(data): - raise ValueError(f"Sample index {sample_index} out of range (0-{len(data)-1})") + raise ValueError(f"Sample index {sample_index} out of range (0-{len(data) - 1})") return [data[sample_index]] return data @@ -106,22 +106,21 @@ def build_session_messages( for idx, msg in enumerate(conv[sk]): speaker = msg.get("speaker", "unknown") text = msg.get("text", "") - messages.append({ - "role": "user", - "text": f"[{speaker}]: {text}", - "speaker": speaker, - "index": idx - }) - - sessions.append({ - "messages": messages, - "meta": { - "sample_id": item["sample_id"], - "session_key": sk, - "date_time": date_time, - "speakers": speakers, - }, - }) + messages.append( + {"role": "user", "text": f"[{speaker}]: {text}", "speaker": speaker, "index": idx} + ) + + sessions.append( + { + "messages": messages, + "meta": { + "sample_id": item["sample_id"], + "session_key": sk, + "date_time": date_time, + "speakers": speakers, + }, + } + ) return sessions @@ -130,6 +129,7 @@ def build_session_messages( # Ingest record helpers (avoid duplicate ingestion) # --------------------------------------------------------------------------- + def load_success_csv(csv_path: str = "./result/import_success.csv") -> set: """加载成功导入的CSV记录,返回已成功的键集合""" success_keys = set() @@ -142,33 +142,48 @@ def load_success_csv(csv_path: str = "./result/import_success.csv") -> set: return success_keys -def write_success_record(record: Dict[str, Any], csv_path: str = "./result/import_success.csv") -> None: +def write_success_record( + record: Dict[str, Any], csv_path: str = "./result/import_success.csv" +) -> None: """写入成功记录到CSV文件""" file_exists = Path(csv_path).exists() - fieldnames = ["timestamp", "sample_id", "session", "date_time", "speakers", - "embedding_tokens", "vlm_tokens", "llm_input_tokens", - "llm_output_tokens", "total_tokens"] + fieldnames = [ + "timestamp", + "sample_id", + "session", + "date_time", + "speakers", + "embedding_tokens", + "vlm_tokens", + "llm_input_tokens", + "llm_output_tokens", + "total_tokens", + ] with open(csv_path, "a", encoding="utf-8", newline="") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) if not file_exists: writer.writeheader() - writer.writerow({ - "timestamp": record["timestamp"], - "sample_id": record["sample_id"], - "session": record["session"], - "date_time": record.get("meta", {}).get("date_time", ""), - "speakers": record.get("meta", {}).get("speakers", ""), - "embedding_tokens": record["token_usage"].get("embedding", 0), - "vlm_tokens": record["token_usage"].get("vlm", 0), - "llm_input_tokens": record["token_usage"].get("llm_input", 0), - "llm_output_tokens": record["token_usage"].get("llm_output", 0), - "total_tokens": record["token_usage"].get("total", 0) - }) - - -def write_error_record(record: Dict[str, Any], error_path: str = "./result/import_errors.log") -> None: + writer.writerow( + { + "timestamp": record["timestamp"], + "sample_id": record["sample_id"], + "session": record["session"], + "date_time": record.get("meta", {}).get("date_time", ""), + "speakers": record.get("meta", {}).get("speakers", ""), + "embedding_tokens": record["token_usage"].get("embedding", 0), + "vlm_tokens": record["token_usage"].get("vlm", 0), + "llm_input_tokens": record["token_usage"].get("llm_input", 0), + "llm_output_tokens": record["token_usage"].get("llm_output", 0), + "total_tokens": record["token_usage"].get("total", 0), + } + ) + + +def write_error_record( + record: Dict[str, Any], error_path: str = "./result/import_errors.log" +) -> None: """写入错误记录到日志文件""" with open(error_path, "a", encoding="utf-8") as f: timestamp = record["timestamp"] @@ -187,7 +202,9 @@ def load_ingest_record(record_path: str = "./result/.ingest_record.json") -> Dic return {} -def save_ingest_record(record: Dict[str, Any], record_path: str = "./result/.ingest_record.json") -> None: +def save_ingest_record( + record: Dict[str, Any], record_path: str = "./result/.ingest_record.json" +) -> None: """Save ingest record to file.""" with open(record_path, "w", encoding="utf-8") as f: json.dump(record, f, indent=2, ensure_ascii=False) @@ -224,27 +241,44 @@ def mark_ingested( # --------------------------------------------------------------------------- # OpenViking import # --------------------------------------------------------------------------- -def _parse_token_usage(task_result: Dict[str, Any]) -> Dict[str, int]: - """解析Token使用数据(从get_task返回的result中提取)""" - result_data = task_result.get("result", {}) - token_usage = result_data.get("token_usage", {}) - llm_tokens = token_usage.get("llm", {}) - embedding_tokens = token_usage.get("embedding", {}) - total_tokens = token_usage.get("total", {}) +def _parse_token_usage(commit_result: Dict[str, Any]) -> Dict[str, int]: + """解析Token使用数据(从commit返回的telemetry或task result中提取)""" + # 尝试从 task result 中提取(task 完成后包含完整 token_usage) + if "result" in commit_result: + result = commit_result["result"] + if "token_usage" in result: + tu = result["token_usage"] + embedding = tu.get("embedding", {}) + llm = tu.get("llm", {}) + # embedding 格式可能是 {"total": N} 或 {"total_tokens": N} + embed_total = embedding.get("total", embedding.get("total_tokens", 0)) + llm_total = llm.get("total", llm.get("total_tokens", 0)) + return { + "embedding": embed_total, + "vlm": llm_total, + "llm_input": llm.get("input", 0), + "llm_output": llm.get("output", 0), + "total": tu.get("total", {}).get("total_tokens", embed_total + llm_total), + } + + # 从 commit 响应的 telemetry 中提取 + telemetry = commit_result.get("telemetry", {}).get("summary", {}) + tokens = telemetry.get("tokens", {}) return { - "embedding": embedding_tokens.get("total_tokens", 0), - "vlm": llm_tokens.get("total_tokens", 0), - "llm_input": llm_tokens.get("prompt_tokens", 0), - "llm_output": llm_tokens.get("completion_tokens", 0), - "total": total_tokens.get("total_tokens", 0) + "embedding": tokens.get("embedding", {}).get("total", 0), + "vlm": tokens.get("llm", {}).get("total", 0), + "llm_input": tokens.get("llm", {}).get("input", 0), + "llm_output": tokens.get("llm", {}).get("output", 0), + "total": tokens.get("total", 0), } async def viking_ingest( messages: List[Dict[str, Any]], openviking_url: str, - semaphore: asyncio.Semaphore, - session_time: Optional[str] = None + session_time: Optional[str] = None, + user_id: Optional[str] = None, + agent_id: Optional[str] = None, ) -> Dict[str, int]: """Save messages to OpenViking via OpenViking SDK client. Returns token usage dict with embedding and vlm token counts. @@ -252,8 +286,9 @@ async def viking_ingest( Args: messages: List of message dicts with role and text openviking_url: OpenViking service URL - semaphore: Async semaphore for concurrency control session_time: Session time string (e.g., "9:36 am on 2 April, 2023") + user_id: User identifier for separate userspace (e.g., "conv-26") + agent_id: Agent identifier for separate agentspace (e.g., "conv-26") """ # 解析 session_time - 为每条消息计算递增的时间戳 base_datetime = None @@ -263,75 +298,79 @@ async def viking_ingest( except ValueError: print(f"Warning: Failed to parse session_time: {session_time}", file=sys.stderr) - # 使用信号量控制并发 - async with semaphore: - # Create client - client = ov.AsyncHTTPClient(url=openviking_url) - await client.initialize() - - try: - # Create session - create_res = await client.create_session() - session_id = create_res["session_id"] - - # Add messages one by one with created_at - for idx, msg in enumerate(messages): - msg_created_at = None - if base_datetime: - # 每条消息递增1秒,确保时间顺序 - msg_dt = base_datetime + timedelta(seconds=idx) - msg_created_at = msg_dt.isoformat() - - await client.add_message( - session_id=session_id, - role=msg["role"], - parts=[{"type": "text", "text": msg["text"]}], - created_at=msg_created_at - ) + # Create client + client = ov.AsyncHTTPClient( + url=openviking_url, + user=user_id, + agent_id=agent_id, + ) + await client.initialize() - # Commit - commit_result = await client.commit_session(session_id, telemetry=True) + try: + # Create session + create_res = await client.create_session() + session_id = create_res["session_id"] + + # Add messages one by one with created_at + for idx, msg in enumerate(messages): + msg_created_at = None + if base_datetime: + # 每条消息递增1秒,确保时间顺序 + msg_dt = base_datetime + timedelta(seconds=idx) + msg_created_at = msg_dt.isoformat() + + await client.add_message( + session_id=session_id, + role=msg["role"], + parts=[{"type": "text", "text": msg["text"]}], + created_at=msg_created_at, + ) - if commit_result.get("status") != "accepted": - raise RuntimeError(f"Commit failed: {commit_result}") + # Commit + result = await client.commit_session(session_id, telemetry=True) - # 获取异步任务ID并轮询任务完成状态 - task_id = commit_result.get("task_id") - if not task_id: - raise RuntimeError(f"No task_id in commit result: {commit_result}") + # Accept both "committed" and "accepted" as success - accepted means the session was archived + if result.get("status") not in ("committed", "accepted"): + raise RuntimeError(f"Commit failed: {result}") + # 等待 task 完成以获取准确 token 消耗 + task_id = result.get("task_id") + if task_id: # 轮询任务状态直到完成 max_attempts = 1200 # 最多等待20分钟 for attempt in range(max_attempts): - task_result = await client.get_task(task_id) - task_status = task_result.get("status") - if task_status == "completed": + task = await client.get_task(task_id) + status = task.get("status") if task else "unknown" + if status == "completed": + token_usage = _parse_token_usage(task) break - elif task_status in ("failed", "cancelled"): - raise RuntimeError(f"Task {task_id} {task_status}: {task_result.get('error')}") - # 等待1秒后重试 + elif status in ("failed", "cancelled", "unknown"): + raise RuntimeError(f"Task {task_id} {status}: {task}") await asyncio.sleep(1) else: raise RuntimeError(f"Task {task_id} timed out after {max_attempts} attempts") + else: + token_usage = {"embedding": 0, "vlm": 0, "total": 0} - # 从任务结果中提取token使用情况 - token_usage = _parse_token_usage(task_result) - - return token_usage + # Get trace_id from commit result + trace_id = result.get("trace_id", "") + return {"token_usage": token_usage, "task_id": task_id, "trace_id": trace_id} - finally: - await client.close() + finally: + await client.close() -def sync_viking_ingest(messages: List[Dict[str, Any]], openviking_url: str, session_time: Optional[str] = None) -> Dict[str, int]: +def sync_viking_ingest( + messages: List[Dict[str, Any]], openviking_url: str, session_time: Optional[str] = None +) -> Dict[str, int]: """Synchronous wrapper for viking_ingest to maintain existing API.""" - semaphore = asyncio.Semaphore(1) # 同步调用时使用信号量为1 - return asyncio.run(viking_ingest(messages, openviking_url, semaphore, session_time)) + return asyncio.run(viking_ingest(messages, openviking_url, session_time)) # --------------------------------------------------------------------------- # Main import logic # --------------------------------------------------------------------------- + def parse_session_range(s: str) -> Tuple[int, int]: """Parse '1-4' or '3' into (lo, hi) inclusive tuple.""" if "-" in s: @@ -349,17 +388,26 @@ async def process_single_session( run_time: str, ingest_record: Dict[str, Any], args: argparse.Namespace, - semaphore: asyncio.Semaphore ) -> Dict[str, Any]: """处理单个会话的导入任务""" try: - token_usage = await viking_ingest(messages, args.openviking_url, semaphore, meta.get("date_time")) - print(f" -> [SUCCESS] [{sample_id}/{session_key}] imported to OpenViking", file=sys.stderr) - - # Extract token counts + # 使用 sample_id 作为 user_id 和 agent_id,实现独立的 userspace/agentspace + result = await viking_ingest( + messages, + args.openviking_url, + meta.get("date_time"), + user_id=str(sample_id), + agent_id=str(sample_id), + ) + token_usage = result["token_usage"] + task_id = result.get("task_id") + trace_id = result.get("trace_id", "") embedding_tokens = token_usage.get("embedding", 0) vlm_tokens = token_usage.get("vlm", 0) - print(f" -> [USAGE] [{sample_id}/{session_key}] Embedding tokens: {embedding_tokens}, VLM tokens: {vlm_tokens}", file=sys.stderr) + print( + f" -> [COMPLETED] [{sample_id}/{session_key}] embed={embedding_tokens}, vlm={vlm_tokens}, task_id={task_id}, trace_id={trace_id}", + file=sys.stderr, + ) # Write success record result = { @@ -370,7 +418,9 @@ async def process_single_session( "meta": meta, "token_usage": token_usage, "embedding_tokens": embedding_tokens, - "vlm_tokens": vlm_tokens + "vlm_tokens": vlm_tokens, + "task_id": task_id, + "trace_id": trace_id, } # 写入成功CSV @@ -392,7 +442,7 @@ async def process_single_session( "sample_id": sample_id, "session": session_key, "status": "error", - "error": str(e) + "error": str(e), } # 写入错误日志 @@ -402,11 +452,46 @@ async def process_single_session( async def run_import(args: argparse.Namespace) -> None: - # 初始化信号量控制并发 - semaphore = asyncio.Semaphore(args.parallel) - session_range = parse_session_range(args.sessions) if args.sessions else None + # 如果指定了 question-index,自动从 evidence 推断需要的 session + if args.question_index is not None and not args.sessions: + # 加载数据获取 question 的 evidence + with open(args.input, "r", encoding="utf-8") as f: + data = json.load(f) + + # 获取 sample + sample_idx = args.sample if args.sample is not None else 0 + if sample_idx < 0 or sample_idx >= len(data): + raise ValueError(f"sample index {sample_idx} out of range") + sample = data[sample_idx] + + # 获取 question 的 evidence + qa_items = sample.get("qa", []) + if args.question_index < 0 or args.question_index >= len(qa_items): + raise ValueError(f"question index {args.question_index} out of range") + qa = qa_items[args.question_index] + evidence_list = qa.get("evidence", []) + + # 从 evidence 提取 session 号 (D1:3 -> session 1) + session_nums = set() + for ev in evidence_list: + try: + # D1:3 -> session 1 + sess_num = int(ev.split(":")[0][1:]) + session_nums.add(sess_num) + except (ValueError, IndexError): + pass + + if session_nums: + min_sess = min(session_nums) + max_sess = max(session_nums) + session_range = (min_sess, max_sess) + print( + f"[INFO] Auto-detected sessions from evidence: {min_sess}-{max_sess}", + file=sys.stderr, + ) + # Handle ingest record operations if args.clear_ingest_record: ingest_record = {} @@ -419,7 +504,10 @@ async def run_import(args: argparse.Namespace) -> None: success_keys = set() if not args.force_ingest: success_keys = load_success_csv(args.success_csv) - print(f"[INFO] Loaded {len(success_keys)} existing success records from {args.success_csv}", file=sys.stderr) + print( + f"[INFO] Loaded {len(success_keys)} existing success records from {args.success_csv}", + file=sys.stderr, + ) # Write run header run_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") @@ -429,19 +517,20 @@ async def run_import(args: argparse.Namespace) -> None: error_count = 0 total_embedding_tokens = 0 total_vlm_tokens = 0 - tasks: List[asyncio.Task] = [] if args.input.endswith(".json"): # LoCoMo JSON format samples = load_locomo_data(args.input, args.sample) - for item in samples: + # 为每个 sample 创建独立的处理协程 + async def process_sample(item): sample_id = item["sample_id"] sessions = build_session_messages(item, session_range) print(f"\n=== Sample {sample_id} ===", file=sys.stderr) print(f" {len(sessions)} session(s) to import", file=sys.stderr) + # 同一 sample 内串行处理所有 sessions for sess in sessions: meta = sess["meta"] messages = sess["messages"] @@ -449,29 +538,35 @@ async def run_import(args: argparse.Namespace) -> None: label = f"{session_key} ({meta['date_time']})" # Skip already ingested sessions unless force-ingest is enabled - if not args.force_ingest and is_already_ingested(sample_id, session_key, ingest_record, success_keys): - print(f" [{label}] [SKIP] already imported (use --force-ingest to reprocess)", file=sys.stderr) - skipped_count += 1 + if not args.force_ingest and is_already_ingested( + sample_id, session_key, ingest_record, success_keys + ): + print( + f" [{label}] [SKIP] already imported (use --force-ingest to reprocess)", + file=sys.stderr, + ) continue # Preview messages - preview = " | ".join([f"{msg['role']}: {msg['text'][:30]}..." for msg in messages[:3]]) + preview = " | ".join( + [f"{msg['role']}: {msg['text'][:30]}..." for msg in messages[:3]] + ) print(f" [{label}] {preview}", file=sys.stderr) - # 创建异步任务 - task = asyncio.create_task( - process_single_session( - messages=messages, - sample_id=sample_id, - session_key=session_key, - meta=meta, - run_time=run_time, - ingest_record=ingest_record, - args=args, - semaphore=semaphore - ) + # 串行执行(等待完成后再处理下一个 session) + await process_single_session( + messages=messages, + sample_id=sample_id, + session_key=session_key, + meta=meta, + run_time=run_time, + ingest_record=ingest_record, + args=args, ) - tasks.append(task) + + # 不同 sample 之间并行执行 + tasks = [asyncio.create_task(process_sample(item)) for item in samples] + results = await asyncio.gather(*tasks, return_exceptions=True) else: # Plain text format @@ -483,20 +578,21 @@ async def run_import(args: argparse.Namespace) -> None: print(f"\n=== Text Session {idx} ===", file=sys.stderr) # Skip already ingested sessions unless force-ingest is enabled - if not args.force_ingest and is_already_ingested("txt", session_key, ingest_record, success_keys): - print(f" [SKIP] already imported (use --force-ingest to reprocess)", file=sys.stderr) + if not args.force_ingest and is_already_ingested( + "txt", session_key, ingest_record, success_keys + ): + print( + f" [SKIP] already imported (use --force-ingest to reprocess)", file=sys.stderr + ) skipped_count += 1 continue # For plain text, all messages as user role messages = [] for i, text in enumerate(session["messages"]): - messages.append({ - "role": "user", - "text": text.strip(), - "speaker": "user", - "index": i - }) + messages.append( + {"role": "user", "text": text.strip(), "speaker": "user", "index": i} + ) preview = " | ".join([f"{msg['role']}: {msg['text'][:30]}..." for msg in messages[:3]]) print(f" {preview}", file=sys.stderr) @@ -511,30 +607,25 @@ async def run_import(args: argparse.Namespace) -> None: run_time=run_time, ingest_record=ingest_record, args=args, - semaphore=semaphore ) ) tasks.append(task) - # 等待所有任务完成 - print(f"\n[INFO] Starting import with {args.parallel} concurrent workers, {len(tasks)} tasks to process", file=sys.stderr) - results = await asyncio.gather(*tasks, return_exceptions=True) - - # 统计结果 - for result in results: - if isinstance(result, Exception): - error_count += 1 - print(f"[UNEXPECTED ERROR] Task failed with exception: {result}", file=sys.stderr) - if hasattr(result, '__traceback__'): - traceback.print_exception(type(result), result, result.__traceback__, file=sys.stderr) - continue + # 等待所有 sample 处理完成 + print( + f"\n[INFO] Starting import with {args.parallel} concurrent workers, {len(tasks)} tasks to process", + file=sys.stderr, + ) + await asyncio.gather(*tasks, return_exceptions=True) - if result["status"] == "success": - success_count += 1 - total_embedding_tokens += result["embedding_tokens"] - total_vlm_tokens += result["vlm_tokens"] - elif result["status"] == "error": - error_count += 1 + # 从成功 CSV 统计结果 + if Path(args.success_csv).exists(): + with open(args.success_csv, "r", encoding="utf-8") as f: + reader = csv.DictReader(f) + for row in reader: + success_count += 1 + total_embedding_tokens += int(row.get("embedding_tokens", 0) or 0) + total_vlm_tokens += int(row.get("vlm_tokens", 0) or 0) # Final summary total_processed = success_count + error_count + skipped_count @@ -547,7 +638,10 @@ async def run_import(args: argparse.Namespace) -> None: print(f"Total Embedding tokens: {total_embedding_tokens}", file=sys.stderr) print(f"Total VLM tokens: {total_vlm_tokens}", file=sys.stderr) if success_count > 0: - print(f"Average Embedding per session: {total_embedding_tokens // success_count}", file=sys.stderr) + print( + f"Average Embedding per session: {total_embedding_tokens // success_count}", + file=sys.stderr, + ) print(f"Average VLM per session: {total_vlm_tokens // success_count}", file=sys.stderr) print(f"\nResults saved to:", file=sys.stderr) print(f" - Success records: {args.success_csv}", file=sys.stderr) @@ -558,12 +652,17 @@ async def run_import(args: argparse.Namespace) -> None: # CLI # --------------------------------------------------------------------------- + def main(): + # 基于脚本所在目录计算默认数据文件路径 + script_dir = Path(__file__).parent.resolve() + default_input = str(script_dir / ".." / "data" / "locomo10.json") + parser = argparse.ArgumentParser(description="Import conversations into OpenViking") parser.add_argument( "--input", - default="./test_data/locomo10.json", - help="Path to input file (.txt or LoCoMo .json)" + default=default_input, + help="Path to input file (.txt or LoCoMo .json)", ) parser.add_argument( "--success-csv", @@ -597,6 +696,12 @@ def main(): default=None, help="LoCoMo JSON: session range, e.g. '1-4' or '3'. Default: all sessions.", ) + parser.add_argument( + "--question-index", + type=int, + default=None, + help="LoCoMo JSON: question index (0-based). When specified, auto-detect required sessions from question's evidence.", + ) parser.add_argument( "--force-ingest", action="store_true", diff --git a/benchmark/locomo/vikingbot/judge.py b/benchmark/locomo/vikingbot/judge.py index 0b2e171f6..65a510fc2 100644 --- a/benchmark/locomo/vikingbot/judge.py +++ b/benchmark/locomo/vikingbot/judge.py @@ -5,8 +5,11 @@ import asyncio from openai import AsyncOpenAI from dotenv import load_dotenv +from pathlib import Path -load_dotenv() +# 加载本地环境变量文件 +env_file = Path.home() / ".openviking_benchmark_env" +load_dotenv(env_file) async def grade_answer( @@ -112,7 +115,12 @@ async def main(): args = parser.parse_args() if not args.token: - print("Error: API token is required, set ARK_API_KEY env var or pass via --token") + print("Error: API token is required") + print("\n请通过以下方式设置 API key:") + print(" 1. 创建 ~/.openviking_benchmark_env 文件,内容如下:") + print(" ARK_API_KEY=你的key") + print(" 2. 或者通过 --token 参数传入") + print(" 3. 或者设置环境变量: export ARK_API_KEY=你的key") exit(1) # 加载数据 diff --git a/benchmark/locomo/vikingbot/run_eval.py b/benchmark/locomo/vikingbot/run_eval.py index 1799aec49..2d38a0454 100644 --- a/benchmark/locomo/vikingbot/run_eval.py +++ b/benchmark/locomo/vikingbot/run_eval.py @@ -7,9 +7,93 @@ import re import threading from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from pathlib import Path -def load_csv_qa(input_path: str, count: int | None = None) -> list[dict]: +def get_evidence_text(evidence_list: list, sample: dict) -> list[str]: + """根据 evidence 列表获取原始对话文本 + + evidence 格式: ['D1:3', 'D2:5'] -> session_1 第3条, session_2 第5条 + """ + if not evidence_list: + return [] + + conv = sample.get("conversation", {}) + results = [] + + for ev in evidence_list: + # 解析 D1:3 -> session_1, index 2 + try: + parts = ev.split(":") + session_num = int(parts[0][1:]) # D1 -> 1 + msg_index = int(parts[1]) - 1 # 3 -> index 2 + + session_key = f"session_{session_num}" + session_messages = conv.get(session_key, []) + + if msg_index < len(session_messages): + msg = session_messages[msg_index] + text = msg.get("text", "") + speaker = msg.get("speaker", "") + results.append(f"{speaker}: {text}") + else: + results.append(f"[{ev}: out of range]") + except (ValueError, IndexError): + results.append(f"[{ev}: invalid format]") + + return results + + +def parse_locomo_datetime(date_str: str) -> datetime | None: + """解析 LoCoMo 时间格式,如 '1:56 pm on 8 May, 2023'""" + try: + # 移除时间部分,只保留日期 "8 May, 2023" + if " on " in date_str: + date_part = date_str.split(" on ")[-1] + return datetime.strptime(date_part.strip(), "%d %B, %Y") + except ValueError: + pass + return None + + +def get_sample_question_time(sample: dict) -> str | None: + """从 sample 的 conversation 中提取最后一个有内容 session 的时间,返回 ISO 格式日期""" + conversation = sample.get("conversation", {}) + + # 找所有 session_N 字段(非 date_time) + session_keys = [ + k for k in conversation.keys() if k.startswith("session_") and "date_time" not in k + ] + if not session_keys: + return None + + # 按 session 编号排序,找到最后一个有内容的 + def get_session_num(key): + try: + return int(key.replace("session_", "")) + except ValueError: + return 0 + + session_keys.sort(key=get_session_num, reverse=True) + + for session_key in session_keys: + if conversation.get(session_key): # 有内容 + # 找到对应的 date_time + session_num = get_session_num(session_key) + dt_key = f"session_{session_num}_date_time" + date_str = conversation.get(dt_key) + if date_str: + dt = parse_locomo_datetime(date_str) + if dt: + return dt.strftime("%Y-%m-%d") + + return None + + +def load_csv_qa( + input_path: str, count: int | None = None, default_time: str | None = None +) -> list[dict]: """从CSV文件加载QA数据,取sample_id和question字段""" qa_list = [] with open(input_path, "r", encoding="utf-8", newline="") as f: @@ -22,6 +106,7 @@ def load_csv_qa(input_path: str, count: int | None = None) -> list[dict]: "answer": row.get("answer", ""), "category": "", "evidence": [], + "question_time": default_time, } ) @@ -31,48 +116,139 @@ def load_csv_qa(input_path: str, count: int | None = None) -> list[dict]: def load_locomo_qa( - input_path: str, sample_index: int | None = None, count: int | None = None + input_path: str, + sample_index: int | None = None, + count: int | None = None, + default_time: str | None = None, + question_index: int | None = None, + invalid_questions: set | None = None, ) -> list[dict]: - """加载LoCoMo数据集的QA部分,支持JSON和CSV格式""" + """加载LoCoMo数据集的QA部分,支持JSON和CSV格式 + + Args: + invalid_questions: 无效题目问题内容集合,用于标记无效题目 + """ if input_path.lower().endswith(".csv"): - return load_csv_qa(input_path, count) + return load_csv_qa(input_path, count, default_time) # 原有JSON格式处理逻辑 with open(input_path, "r", encoding="utf-8") as f: data = json.load(f) qa_list = [] + # 支持数字索引或 sample_id (如 "conv-26") if sample_index is not None: - if sample_index < 0 or sample_index >= len(data): - raise ValueError(f"sample index {sample_index} out of range (0-{len(data) - 1})") - samples = [data[sample_index]] + # 尝试解析为数字索引 + try: + idx = int(sample_index) + if idx < 0 or idx >= len(data): + raise ValueError(f"sample index {idx} out of range (0-{len(data) - 1})") + samples = [data[idx]] + except ValueError: + # 尝试匹配 sample_id + matched = [s for s in data if s.get("sample_id") == sample_index] + if not matched: + raise ValueError(f"sample_id '{sample_index}' not found") + samples = matched else: samples = data for sample in samples: sample_id = sample.get("sample_id", "") - for qa in sample.get("qa", []): + question_time = get_sample_question_time(sample) + qa_items = sample.get("qa", []) + + # 如果指定了 question_index,只返回那一个问题 + if question_index is not None: + if question_index < 0 or question_index >= len(qa_items): + raise ValueError( + f"question index {question_index} out of range (0-{len(qa_items) - 1})" + ) + qa = qa_items[question_index] + evidence_list = qa.get("evidence", []) + question_id = f"{sample_id}_qa{question_index}" qa_list.append( { "sample_id": sample_id, + "question_id": question_id, + "question_index": question_index, "question": qa["question"], "answer": qa["answer"], "category": qa.get("category", ""), - "evidence": qa.get("evidence", []), + "evidence": evidence_list, + "evidence_text": get_evidence_text(evidence_list, sample), + "question_time": question_time, + "is_invalid": qa["question"] in invalid_questions + if invalid_questions + else False, } ) + else: + for q_idx, qa in enumerate(qa_items): + evidence_list = qa.get("evidence", []) + question_id = f"{sample_id}_qa{q_idx}" + qa_list.append( + { + "sample_id": sample_id, + "question_id": question_id, + "question_index": q_idx, + "question": qa["question"], + "answer": qa["answer"], + "category": qa.get("category", ""), + "evidence": evidence_list, + "evidence_text": get_evidence_text(evidence_list, sample), + "question_time": question_time, + "is_invalid": qa["question"] in invalid_questions + if invalid_questions + else False, + } + ) if count is not None: qa_list = qa_list[:count] return qa_list -def run_vikingbot_chat(question: str) -> tuple[str, dict, float, int, list]: +def run_vikingbot_chat( + question: str, + question_time: str | None = None, + sample_id: str | None = None, + question_id: str | None = None, +) -> tuple[str, dict, float, int, list]: """执行vikingbot chat命令,返回回答、token使用情况、耗时(秒)、迭代次数、使用的工具列表""" - input = f"Answer the question directly: {question}" + # 先执行 /new 命令清除会话 + if sample_id: + new_cmd = [ + "vikingbot", + "chat", + "-m", + "/new", + "-e", + "--sender", + sample_id, + "--session", + question_id, + ] + try: + # print(f'new_cmd={new_cmd}') + subprocess.run(new_cmd, capture_output=True, text=True, timeout=60) + except Exception: + # 忽略 /new 命令的错误 + pass + + # 如果有 question_time,注入到 prompt 中 + if question_time: + input = f"Current date: {question_time}. Answer the question directly: {question}" + else: + input = f"Answer the question directly: {question}" + cmd = ["vikingbot", "chat", "-m", input, "-e"] + # 添加 --sender 作为 user_id,--session 作为 agent_id,实现访问独立 userspace + if sample_id: + cmd.extend(["--sender", sample_id, "--session", question_id]) start_time = time.time() try: + # print(f'cmd={cmd}') result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=300) end_time = time.time() time_cost = end_time - start_time @@ -114,50 +290,95 @@ def run_vikingbot_chat(question: str) -> tuple[str, dict, float, int, list]: def load_processed_questions(output_path: str) -> set: - """加载已处理的问题集合,避免重复执行""" - processed = set() - if os.path.exists(output_path): - with open(output_path, "r", encoding="utf-8", newline="") as f: - reader = csv.DictReader(f) - for row in reader: - processed.add(row["question"]) - return processed + """加载已处理的问题集合(已禁用,每次重新运行)""" + # 注意:去重逻辑已禁用,每次运行都会重新执行所有问题 + return set() def main(): + # 基于脚本所在目录计算默认数据文件路径 + script_dir = Path(__file__).parent.resolve() + default_input = str(script_dir / ".." / "data" / "locomo10.json") + default_errors = str(script_dir / ".." / "data" / "errors.json") + parser = argparse.ArgumentParser(description="VikingBot QA evaluation script") parser.add_argument( "input", nargs="?", - default="./test_data/locomo10.json", - help="Path to locomo10.json file, default: ./test_data/locomo10.json", + default=default_input, + help="Path to locomo10.json file", ) parser.add_argument( "--output", default="./result/locomo_qa_result.csv", help="Path to output csv file, default: ./result/locomo_qa_result.csv", ) + parser.add_argument( + "--errors", + default=default_errors, + help="Path to invalid questions JSON file", + ) parser.add_argument( "--sample", + type=str, + default=None, + help="LoCoMo sample index (0-based) or sample_id (e.g., conv-26)", + ) + parser.add_argument( + "--question-index", type=int, default=None, - help="LoCoMo sample index (0-based), default all samples", + help="Question index (0-based) for single question testing", ) parser.add_argument( "--count", type=int, default=None, help="Number of QA questions to run, default all" ) parser.add_argument( - "--threads", type=int, default=5, help="Number of concurrent threads, default: 5" + "--threads", type=int, default=40, help="Number of concurrent threads, default: 40" + ) + parser.add_argument( + "--update-mode", + action="store_true", + help="Update mode: if output file exists, update matching question_index rows instead of overwriting", ) args = parser.parse_args() + # 如果指定了 question-index,自动设置 count=1 + if args.question_index is not None and args.count is None: + args.count = 1 + # 确保输出目录存在 os.makedirs(os.path.dirname(args.output), exist_ok=True) - # 加载QA数据 - qa_list = load_locomo_qa(args.input, args.sample, args.count) + # 加载无效题目集合(按问题内容匹配,因为 errors.json 索引可能与数据不匹配) + invalid_questions = set() + errors_path = os.path.expanduser(args.errors) + if os.path.exists(errors_path): + with open(errors_path, "r", encoding="utf-8") as f: + errors_data = json.load(f) + # 按问题内容建立集合 + if errors_data and isinstance(errors_data[0], dict): + invalid_questions = {item["question"] for item in errors_data} + else: + invalid_questions = set(errors_data) + print(f"Loaded {len(invalid_questions)} invalid questions from {errors_path}") + else: + print(f"No errors file found at {errors_path}, is_invalid will be False for all questions") + + # 加载QA数据(所有题目,包括无效题目,只标记 is_invalid) + qa_list = load_locomo_qa( + args.input, + args.sample, + args.count, + question_index=args.question_index, + invalid_questions=invalid_questions, + ) total = len(qa_list) + # 过滤掉 category=5 的问题 + qa_list = [qa for qa in qa_list if str(qa.get("category")) != "5"] + print(f"Filtered to {len(qa_list)} questions after removing category=5") + # 加载已处理的问题 processed_questions = load_processed_questions(args.output) remaining = total - len(processed_questions) @@ -167,77 +388,135 @@ def main(): fieldnames = [ "sample_id", + "question_index", + "result", + "is_invalid", "question", "answer", + "category", + "question_time", + "evidence", + "evidence_text", "response", "token_usage", "time_cost", "iteration", "tools_used_names", - "result", ] - # 打开CSV文件,不存在则创建写表头,存在则追加 - file_exists = os.path.exists(args.output) + # 创建线程锁,确保多线程写文件安全 write_lock = threading.Lock() - with open(args.output, "a+", encoding="utf-8", newline="") as f: - writer = csv.DictWriter(f, fieldnames=fieldnames) - if not file_exists: + # 存储处理后的新行 + new_rows = [] + processed_count = 0 + + # 过滤掉已经处理过的问题 + remaining_qa = [qa for qa in qa_list if qa["question"] not in processed_questions] + remaining_count = len(remaining_qa) + print( + f"Starting evaluation with {args.threads} concurrent threads, {remaining_count} questions to process" + ) + + def process_qa(qa_item, idx, total_count): + """单个QA处理函数,供多线程调用""" + question = qa_item["question"] + answer = qa_item["answer"] + question_time = qa_item.get("question_time") + # 使用 question_id 作为 session_id,实现完全独立并行 + sample_id = qa_item.get("sample_id") + question_id = qa_item.get("question_id") + print(f"Processing {idx}/{total_count}: {question[:60]}...") + if question_time: + print(f" [time context: {question_time}]") + + response, token_usage, time_cost, iteration, tools_used_names = run_vikingbot_chat( + question, question_time, sample_id, question_id + ) + + row = { + "sample_id": qa_item["sample_id"], + "question_index": qa_item.get("question_index", ""), + "result": "", + "question": question, + "answer": answer, + "category": qa_item.get("category", ""), + "question_time": question_time or "", + "evidence": json.dumps(qa_item.get("evidence", [])), + "evidence_text": json.dumps(qa_item.get("evidence_text", [])), + "response": response, + "token_usage": json.dumps(token_usage, ensure_ascii=False), + "time_cost": round(time_cost, 2), + "iteration": iteration, + "tools_used_names": json.dumps(tools_used_names, ensure_ascii=False), + "is_invalid": qa_item.get("is_invalid", False), + } + + # 线程安全的结果收集 + with write_lock: + nonlocal processed_count + new_rows.append(row) + processed_questions.add(question) + processed_count += 1 + print(f"Completed {processed_count}/{total_count}, time cost: {round(time_cost, 2)}s") + return True + + # 使用线程池处理:全局并行,每个 question 独立 session + with ThreadPoolExecutor(max_workers=args.threads) as executor: + # 提交所有任务 + futures = [] + for idx, qa_item in enumerate(remaining_qa, 1): + futures.append(executor.submit(process_qa, qa_item, idx, remaining_count)) + + # 等待所有任务完成 + for future in as_completed(futures): + try: + future.result() + except Exception as e: + print(f"Error processing QA item: {str(e)}") + + # 写文件逻辑 + if args.update_mode and os.path.exists(args.output): + # 更新模式:读取现有文件,更新匹配行 + print(f"Update mode: updating existing file {args.output}") + with open(args.output, "r", encoding="utf-8", newline="") as f: + reader = csv.DictReader(f) + existing_rows = list(reader) + existing_fieldnames = reader.fieldnames or fieldnames + + # 更新匹配的行 + updated_count = 0 + for new_row in new_rows: + q_idx = str(new_row.get("question_index", "")) + found = False + for row in existing_rows: + if str(row.get("question_index", "")) == q_idx: + row.update(new_row) + found = True + updated_count += 1 + break + if not found: + existing_rows.append(new_row) + updated_count += 1 + + # 写回文件 + with open(args.output, "w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=existing_fieldnames) + writer.writeheader() + writer.writerows(existing_rows) + + print(f"Updated {updated_count} rows in {args.output}") + else: + # 普通模式:覆盖写入 + if os.path.exists(args.output): + os.remove(args.output) + + with open(args.output, "w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() - f.flush() - - processed_count = len(processed_questions) - # 过滤掉已经处理过的问题 - remaining_qa = [qa for qa in qa_list if qa["question"] not in processed_questions] - remaining_count = len(remaining_qa) - print(f"Starting evaluation with {args.threads} concurrent threads, {remaining_count} questions to process") - - def process_qa(qa_item, idx, total_count): - """单个QA处理函数,供多线程调用""" - question = qa_item["question"] - answer = qa_item["answer"] - print(f"Processing {idx}/{total_count}: {question[:60]}...") - - response, token_usage, time_cost, iteration, tools_used_names = run_vikingbot_chat(question) - - row = { - "sample_id": qa_item["sample_id"], - "question": question, - "answer": answer, - "response": response, - "token_usage": json.dumps(token_usage, ensure_ascii=False), - "time_cost": round(time_cost, 2), - "iteration": iteration, - "tools_used_names": json.dumps(tools_used_names, ensure_ascii=False), - "result": "", - } - - # 线程安全的文件写入 - with write_lock: - nonlocal processed_count - writer.writerow(row) - f.flush() - processed_questions.add(question) - processed_count += 1 - print(f"Completed {processed_count}/{total}, time cost: {round(time_cost, 2)}s") - return True - - # 使用线程池处理 - with ThreadPoolExecutor(max_workers=args.threads) as executor: - # 提交所有任务 - futures = [] - for idx, qa_item in enumerate(remaining_qa, 1): - futures.append(executor.submit(process_qa, qa_item, idx, remaining_count)) - - # 等待所有任务完成 - for future in as_completed(futures): - try: - future.result() - except Exception as e: - print(f"Error processing QA item: {str(e)}") - - print(f"Evaluation completed, results saved to {args.output}") + writer.writerows(new_rows) + + print(f"Evaluation completed, results saved to {args.output}") if __name__ == "__main__": diff --git a/benchmark/locomo/vikingbot/run_full_eval.sh b/benchmark/locomo/vikingbot/run_full_eval.sh index 72d58f739..08746e774 100755 --- a/benchmark/locomo/vikingbot/run_full_eval.sh +++ b/benchmark/locomo/vikingbot/run_full_eval.sh @@ -2,29 +2,31 @@ set -e -# Step 1: 导入数据 -echo "[1/4] 导入数据..." -python bot/eval/locomo/import_to_ov.py --input ~/.test_data/locomo10.json --force-ingest - -echo "等待 3 分钟..." -sleep 180 +# 基于脚本所在目录计算数据文件路径 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INPUT_FILE="$SCRIPT_DIR/../data/locomo10.json" + +# Step 1: 导入数据(可跳过) +if [ "$1" != "--skip-import" ]; then + echo "[1/4] 导入数据..." + python benchmark/locomo/vikingbot/import_to_ov.py --input $INPUT_FILE --force-ingest + echo "等待 1 分钟..." + sleep 60 +else + echo "[1/4] 跳过导入数据..." +fi # Step 2: 评估 echo "[2/4] 评估..." -python bot/eval/locomo/run_eval.py ~/.test_data/locomo_qa_1528.csv --output ./result/locomo_result_multi_read_all.csv --threads 20 +python benchmark/locomo/vikingbot/run_eval.py $INPUT_FILE --output ./result/locomo_result_multi_read_all.csv -echo "等待 3 分钟..." -sleep 180 # Step 3: 裁判打分 echo "[3/4] 裁判打分..." -python bot/eval/locomo/judge.py --token 0a2b68f6-4df3-48f5-81b9-f85fe0af9cef --input ./result/locomo_result_multi_read_all.csv --parallel 10 - -echo "等待 3 分钟..." -sleep 180 +python benchmark/locomo/vikingbot/judge.py --input ./result/locomo_result_multi_read_all.csv --parallel 40 # Step 4: 计算结果 echo "[4/4] 计算结果..." -python bot/eval/locomo/stat_judge_result.py --input ./result/locomo_result_multi_read_all.csv +python benchmark/locomo/vikingbot/stat_judge_result.py --input ./result/locomo_result_multi_read_all.csv echo "完成!" \ No newline at end of file diff --git a/benchmark/locomo/vikingbot/stat_judge_result.py b/benchmark/locomo/vikingbot/stat_judge_result.py index 2d7ebd8d6..298d0c708 100644 --- a/benchmark/locomo/vikingbot/stat_judge_result.py +++ b/benchmark/locomo/vikingbot/stat_judge_result.py @@ -17,6 +17,7 @@ def main(): print(f"Error: File not found: {args.input}") exit(1) + # 统计所有题目 (排除 category=5) correct = 0 wrong = 0 total_time = 0.0 @@ -26,23 +27,53 @@ def main(): valid_rows = 0 total_iteration = 0 + # 统计 is_valid=True 的题目 (排除 category=5) + valid_only_correct = 0 + valid_only_wrong = 0 + valid_only_total_time = 0.0 + valid_only_total_prompt_tokens = 0 + valid_only_total_completion_tokens = 0 + valid_only_total_tokens = 0 + valid_only_rows = 0 + valid_only_total_iteration = 0 + with open(args.input, "r", encoding="utf-8", newline="") as f: reader = csv.DictReader(f) for row in reader: + # 检查 category 是否为 5,跳过 + category = row.get("category", "") + if category == "5": + continue + valid_rows += 1 + + # 检查是否是无效题目 + is_invalid = row.get("is_invalid", "").lower() == "true" + is_valid = not is_invalid + # 统计结果 result = row.get("result", "").strip().upper() if result == "CORRECT": correct += 1 + if is_valid: + valid_only_correct += 1 elif result == "WRONG": wrong += 1 + if is_valid: + valid_only_wrong += 1 total_iteration += int(row.get("iteration", "0")) + if is_valid: + valid_only_total_iteration += int(row.get("iteration", "0")) + # 统计耗时 time_cost = row.get("time_cost", "") if time_cost: try: - total_time += float(time_cost) + time_val = float(time_cost) + total_time += time_val + if is_valid: + valid_only_total_time += time_val except (ValueError, TypeError): pass @@ -54,15 +85,45 @@ def main(): total_prompt_tokens += token_data.get("prompt_tokens", 0) total_completion_tokens += token_data.get("completion_tokens", 0) total_tokens += token_data.get("total_tokens", 0) + + if is_valid: + valid_only_total_prompt_tokens += token_data.get("prompt_tokens", 0) + valid_only_total_completion_tokens += token_data.get("completion_tokens", 0) + valid_only_total_tokens += token_data.get("total_tokens", 0) except json.JSONDecodeError: pass + if is_valid: + valid_only_rows += 1 + total_graded = correct + wrong accuracy = correct / total_graded if total_graded > 0 else 0.0 avg_time = total_time / valid_rows if valid_rows > 0 else 0.0 + # is_valid=True 题目的统计 (排除 category=5) + valid_only_total_graded = valid_only_correct + valid_only_wrong + valid_only_accuracy = ( + valid_only_correct / valid_only_total_graded if valid_only_total_graded > 0 else 0.0 + ) + valid_only_avg_time = valid_only_total_time / valid_only_rows if valid_only_rows > 0 else 0.0 + + # 平均 token 消耗 + avg_prompt_tokens = total_prompt_tokens / valid_rows if valid_rows > 0 else 0.0 + avg_completion_tokens = total_completion_tokens / valid_rows if valid_rows > 0 else 0.0 + avg_total_tokens = total_tokens / valid_rows if valid_rows > 0 else 0.0 + + valid_only_avg_prompt_tokens = ( + valid_only_total_prompt_tokens / valid_only_rows if valid_only_rows > 0 else 0.0 + ) + valid_only_avg_completion_tokens = ( + valid_only_total_completion_tokens / valid_only_rows if valid_only_rows > 0 else 0.0 + ) + valid_only_avg_total_tokens = ( + valid_only_total_tokens / valid_only_rows if valid_only_rows > 0 else 0.0 + ) + output_lines = [ - "=== Judge Result Statistics ===", + "=== Judge Result Statistics (excluding category=5) ===", f"Total rows: {valid_rows}", f"Graded rows: {total_graded}", f"Correct: {correct}", @@ -74,6 +135,25 @@ def main(): f" Total prompt tokens: {total_prompt_tokens}", f" Total completion tokens: {total_completion_tokens}", f" Total tokens: {total_tokens}", + f" Avg prompt tokens: {avg_prompt_tokens:.2f}", + f" Avg completion tokens: {avg_completion_tokens:.2f}", + f" Avg total tokens: {avg_total_tokens:.2f}", + "", + "=== Valid Questions Only (is_valid=True, excluding category=5) ===", + f"Valid rows: {valid_only_rows}", + f"Valid graded rows: {valid_only_total_graded}", + f"Valid correct: {valid_only_correct}", + f"Valid wrong: {valid_only_wrong}", + f"Valid accuracy: {valid_only_accuracy:.2%}", + f"\nAverage time cost: {valid_only_avg_time:.2f}s", + f"\nAverage iteration: {valid_only_total_iteration / valid_only_rows if valid_only_rows > 0 else 0.0:.2f}", + f"\nToken usage:", + f" Total prompt tokens: {valid_only_total_prompt_tokens}", + f" Total completion tokens: {valid_only_total_completion_tokens}", + f" Total tokens: {valid_only_total_tokens}", + f" Avg prompt tokens: {valid_only_avg_prompt_tokens:.2f}", + f" Avg completion tokens: {valid_only_avg_completion_tokens:.2f}", + f" Avg total tokens: {valid_only_avg_total_tokens:.2f}", ] # 打印到控制台 diff --git a/bot/scripts/restart_openviking_server.sh b/bot/scripts/restart_openviking_server.sh index d8f1caee4..167d5a0a7 100755 --- a/bot/scripts/restart_openviking_server.sh +++ b/bot/scripts/restart_openviking_server.sh @@ -42,8 +42,29 @@ echo "Bot URL: $BOT_URL" echo "Bot Port: $BOT_PORT" echo "" -# Step 0: Kill existing vikingbot processes -echo "Step 0: Stopping existing vikingbot processes..." +# Step 0: Kill process on port and delete data directory +echo "Step 0: Killing process on port $PORT..." +if lsof -i :"$PORT" > /dev/null 2>&1; then + pid=$(lsof -ti :"$PORT") + kill -9 "$pid" 2>/dev/null || true + sleep 1 + echo " ✓ Killed process $pid on port $PORT" +else + echo " ✓ No process found on port $PORT" +fi + +echo "" +echo "Step 0b: Deleting data directory /Users/bytedance/.openviking/data..." +if [ -d "/Users/bytedance/.openviking/data" ]; then + rm -rf /Users/bytedance/.openviking/data + echo " ✓ Deleted /Users/bytedance/.openviking/data" +else + echo " ✓ Data directory does not exist" +fi + +# Kill existing vikingbot processes +echo "" +echo "Step 0c: Stopping existing vikingbot processes..." if pgrep -f "vikingbot.*openapi" > /dev/null 2>&1 || pgrep -f "vikingbot.*gateway" > /dev/null 2>&1; then pkill -f "vikingbot.*openapi" 2>/dev/null || true pkill -f "vikingbot.*gateway" 2>/dev/null || true @@ -53,36 +74,20 @@ else echo " ✓ No existing vikingbot processes found" fi -# Step 1: Kill existing openviking-server processes -echo "Step 1: Stopping existing openviking-server processes..." -if pgrep -f "openviking-server" > /dev/null 2>&1; then - pkill -f "openviking-server" 2>/dev/null || true - sleep 2 - # Force kill if still running - if pgrep -f "openviking-server" > /dev/null 2>&1; then - echo " Force killing remaining processes..." - pkill -9 -f "openviking-server" 2>/dev/null || true - sleep 1 - fi - echo " ✓ Stopped existing processes" -else - echo " ✓ No existing processes found" -fi - -# Step 2: Wait for port to be released +# Step 1: Verify port is free echo "" -echo "Step 2: Waiting for port $PORT to be released..." -for i in {1..10}; do - if ! lsof -i :"$PORT" > /dev/null 2>&1; then - echo " ✓ Port $PORT is free" - break - fi +echo "Step 1: Verifying port $PORT is free..." +if lsof -i :"$PORT" > /dev/null 2>&1; then + echo " ✗ Port $PORT is still in use, trying to force kill..." + pid=$(lsof -ti :"$PORT") + kill -9 "$pid" 2>/dev/null || true sleep 1 -done +fi +echo " ✓ Port $PORT is free" -# Step 3: Start openviking-server with --with-bot +# Step 2: Start openviking-server with --with-bot echo "" -echo "Step 3: Starting openviking-server with Bot API..." +echo "Step 2: Starting openviking-server with Bot API..." echo " Command: openviking-server --with-bot --port $PORT --bot-url $BOT_URL" echo "" @@ -102,9 +107,9 @@ openviking-server \ SERVER_PID=$! echo " Server PID: $SERVER_PID" -# Step 4: Wait for server to start +# Step 3: Wait for server to start echo "" -echo "Step 4: Waiting for server to be ready..." +echo "Step 3: Waiting for server to be ready..." sleep 3 # First check if server is responding at all diff --git a/bot/scripts/test_restart_openviking_server.sh b/bot/scripts/test_restart_openviking_server.sh index ef8a86af3..547d62a6d 100755 --- a/bot/scripts/test_restart_openviking_server.sh +++ b/bot/scripts/test_restart_openviking_server.sh @@ -55,17 +55,9 @@ fi mkdir -p "$TEST_DATA_DIR" echo " ✓ Created clean $TEST_DATA_DIR" -# Step 1: Kill existing vikingbot processes +# Step 1: Clean up test data directory (skip vikingbot kill) echo "" -echo "Step 1: Stopping existing vikingbot processes..." -if pgrep -f "vikingbot.*openapi" > /dev/null 2>&1 || pgrep -f "vikingbot.*gateway" > /dev/null 2>&1; then - pkill -f "vikingbot.*openapi" 2>/dev/null || true - pkill -f "vikingbot.*gateway" 2>/dev/null || true - sleep 2 - echo " ✓ Stopped existing vikingbot processes" -else - echo " ✓ No existing vikingbot processes found" -fi +echo "Step 1: Skipping vikingbot kill (will only kill by port)..." # Step 2: Kill existing openviking-server on specific port echo "" @@ -73,8 +65,6 @@ echo "Step 2: Stopping openviking-server on port $PORT..." PID=$(lsof -ti :$PORT 2>/dev/null || true) if [ -n "$PID" ]; then echo " Found PID: $PID" - pkill -f "vikingbot.*openapi" 2>/dev/null || true - pkill -f "vikingbot.*gateway" 2>/dev/null || true kill $PID 2>/dev/null || true sleep 2 # Force kill if still running @@ -124,10 +114,7 @@ echo "" export OPENVIKING_CONFIG_FILE="$TEST_CONFIG" # Start server -openviking-server \ - --with-bot \ - --port "$PORT" \ - --bot-url "$BOT_URL" +openviking-server --port "$PORT" SERVER_PID=$! echo " Server PID: $SERVER_PID" diff --git a/bot/vikingbot/agent/context.py b/bot/vikingbot/agent/context.py index 54a5b47ae..73ec56d39 100644 --- a/bot/vikingbot/agent/context.py +++ b/bot/vikingbot/agent/context.py @@ -173,14 +173,14 @@ async def _build_user_memory( viking_memory = await self.memory.get_viking_memory_context( current_message=current_message, workspace_id=workspace_id, sender_id=sender_id ) + logger.info(f'viking_memory={viking_memory}') cost = round(_time.time() - start, 2) logger.info( f"[READ_USER_MEMORY]: cost {cost}s, memory={viking_memory[:50] if viking_memory else 'None'}" ) if viking_memory: parts.append( - f"## Long term memory about this conversation.\n" - f"You do not need to use tool to search again:\n" + f"## openviking_search(query=[user_query])\n" f"{viking_memory}" ) diff --git a/bot/vikingbot/agent/loop.py b/bot/vikingbot/agent/loop.py index 2bfef8e51..aee03ec64 100644 --- a/bot/vikingbot/agent/loop.py +++ b/bot/vikingbot/agent/loop.py @@ -440,6 +440,18 @@ async def check_long_running(): else: cmd = msg.content.strip().lower() if cmd == "/new": + # Clone session for async consolidation, then immediately clear original + if not self._check_cmd_auth(msg): + return OutboundMessage( + session_key=msg.session_key, content="🐈 Sorry, you are not authorized to use this command.", + metadata=msg.metadata + ) + session.clear() + await self.sessions.save(session) + return OutboundMessage( + session_key=msg.session_key, content="🐈 New session started. Session history droped.", metadata=msg.metadata + ) + elif cmd == "/compact": # Clone session for async consolidation, then immediately clear original if not self._check_cmd_auth(msg): return OutboundMessage( diff --git a/bot/vikingbot/agent/memory.py b/bot/vikingbot/agent/memory.py index bfe0ed4d7..abc8f21f0 100644 --- a/bot/vikingbot/agent/memory.py +++ b/bot/vikingbot/agent/memory.py @@ -23,20 +23,90 @@ def read_long_term(self) -> str: return self.memory_file.read_text(encoding="utf-8") return "" - def _parse_viking_memory(self, result: Any) -> str: - if result and len(result) > 0: - user_memories = [] - for idx, memory in enumerate(result, start=1): - user_memories.append( - f"\n" - f" {getattr(memory, 'abstract', '')}\n" - f" {getattr(memory, 'uri', '')}\n" - f" {getattr(memory, 'is_leaf', False)}\n" - f" {getattr(memory, 'score', 0.0)}\n" + async def _parse_viking_memory( + self, result: Any, client: Any, min_score: float = 0.3, max_chars: int = 4000 + ) -> str: + """Parse viking memory with score filtering and character limit. + Automatically reads full content for memories above threshold. + + Args: + result: Memory search results + client: VikingClient instance to read content + min_score: Minimum score threshold (default: 0.4) + max_chars: Maximum character limit for output (default: 4000) + + Returns: + Formatted memory string within character limit + """ + if not result or len(result) == 0: + return "" + + # Filter by min_score and sort by score descending + filtered_memories = [ + memory for memory in result if getattr(memory, "score", 0.0) >= min_score + ] + filtered_memories.sort(key=lambda m: getattr(m, "score", 0.0), reverse=True) + + user_memories = [] + total_chars = 0 + + for idx, memory in enumerate(filtered_memories, start=1): + uri = getattr(memory, "uri", "") + abstract = getattr(memory, "abstract", "") + score = getattr(memory, "score", 0.0) + + # First, try to build full memory with content + try: + content = await client.read_content(uri, level="read") + except Exception: + content = "" + + if content: + # Try full version first (no abstract when content is present) + memory_str = ( + f'\n' + f" {uri}\n" + f" {score}\n" + f" {content}\n" + f"" + ) + else: + # No content available, use link-only version + memory_str = ( + f'\n' + f" {uri}\n" + f" {score}\n" f"" ) - return "\n".join(user_memories) - return "" + + # Check if adding this memory would exceed the limit + memory_chars = len(memory_str) + if user_memories: + memory_chars += 1 + + if total_chars + memory_chars <= max_chars: + user_memories.append(memory_str) + total_chars += memory_chars + else: + # If full version is too big, try link-only version + link_only_str = ( + f'\n' + f" {uri}\n" + f" {score}\n" + f"" + ) + link_chars = len(link_only_str) + if user_memories: + link_chars += 1 + + if total_chars + link_chars <= max_chars: + user_memories.append(link_only_str) + total_chars += link_chars + else: + # Even link-only is too big, skip this memory + continue + + return "\n".join(user_memories) def write_long_term(self, content: str) -> None: self.memory_file.write_text(content, encoding="utf-8") @@ -49,21 +119,36 @@ def get_memory_context(self) -> str: long_term = self.read_long_term() return f"## Long-term Memory\n{long_term}" if long_term else "" - async def get_viking_memory_context(self, current_message: str, workspace_id: str, sender_id: str) -> str: + async def get_viking_memory_context( + self, current_message: str, workspace_id: str, sender_id: str + ) -> str: try: config = load_config().ov_server admin_user_id = config.admin_user_id - user_id = sender_id if config.mode == "remote" else admin_user_id + user_id = sender_id + logger.info(f'workspace_id={workspace_id}') + logger.info(f'user_id={user_id}') + logger.info(f'admin_user_id={admin_user_id}') client = await VikingClient.create(agent_id=workspace_id) - result = await client.search_memory(query=current_message, user_id=user_id, agent_user_id=admin_user_id, limit=5) + result = await client.search_memory( + query=current_message, user_id=user_id, agent_user_id=admin_user_id, limit=30 + ) if not result: return "" - user_memory = self._parse_viking_memory(result["user_memory"]) - agent_memory = self._parse_viking_memory(result["agent_memory"]) - return ( - f"### user memories:\n{user_memory}\n" - f"### agent memories:\n{agent_memory}" - ) + + # Log raw search results for debugging + memory_list = [] + memory_list.append(f'user_memory[{len(result['user_memory'])}]:') + + for i, mem in enumerate(result['user_memory']): + memory_list.append(f"{i},{getattr(mem, 'uri', '')},{getattr(mem, 'score', 0)}") + memory_list.append(f'agent_memory[{len(result['agent_memory'])}]:') + for i, mem in enumerate(result['agent_memory']): + memory_list.append(f"{i},{getattr(mem, 'uri', '')},{getattr(mem, 'score', 0)}") + logger.info(f"[RAW_MEMORIES]\n{'\n'.join(memory_list)}") + user_memory = await self._parse_viking_memory(result["user_memory"], client, min_score=0.35) + agent_memory = await self._parse_viking_memory(result["agent_memory"], client, min_score=0.35, max_chars=2000) + return f"### user memories:\n{user_memory}\n### agent memories:\n{agent_memory}" except Exception as e: logger.error(f"[READ_USER_MEMORY]: search error. {e}") return "" @@ -73,4 +158,4 @@ async def get_viking_user_profile(self, workspace_id: str, user_id: str) -> str: result = await client.read_user_profile(user_id) if not result: return "" - return result \ No newline at end of file + return result diff --git a/bot/vikingbot/openviking_mount/ov_server.py b/bot/vikingbot/openviking_mount/ov_server.py index daa139799..7acb94beb 100644 --- a/bot/vikingbot/openviking_mount/ov_server.py +++ b/bot/vikingbot/openviking_mount/ov_server.py @@ -444,7 +444,9 @@ async def commit(self, session_id: str, messages: list[dict[str, Any]], user_id: if not parts: continue - await session.add_message(role=role, parts=parts) + # 获取消息的时间戳,如果没有则使用当前时间 + created_at = message.get("timestamp") + await session.add_message(role=role, parts=parts, created_at=created_at) result = await session.commit_async() if client is not self.client: diff --git a/openviking/client/session.py b/openviking/client/session.py index d569ab63a..27b6b33b6 100644 --- a/openviking/client/session.py +++ b/openviking/client/session.py @@ -40,6 +40,7 @@ async def add_message( role: str, content: Optional[str] = None, parts: Optional[List[Part]] = None, + created_at: Optional[str] = None, ) -> Dict[str, Any]: """Add a message to the session. @@ -47,6 +48,7 @@ async def add_message( role: Message role (e.g., "user", "assistant") content: Text content (simple mode) parts: Parts list (TextPart, ContextPart, ToolPart) + created_at: Message creation time (ISO format string). If not provided, current time is used. If both content and parts are provided, parts takes precedence. @@ -55,8 +57,12 @@ async def add_message( """ if parts is not None: parts_dicts = [asdict(p) for p in parts] - return await self._client.add_message(self.session_id, role, parts=parts_dicts) - return await self._client.add_message(self.session_id, role, content=content) + return await self._client.add_message( + self.session_id, role, parts=parts_dicts, created_at=created_at + ) + return await self._client.add_message( + self.session_id, role, content=content, created_at=created_at + ) async def commit(self, telemetry: TelemetryRequest = False) -> Dict[str, Any]: """Commit the session (archive messages and extract memories). diff --git a/openviking/core/directories.py b/openviking/core/directories.py index c3216bd98..e0dedb0f0 100644 --- a/openviking/core/directories.py +++ b/openviking/core/directories.py @@ -204,6 +204,7 @@ async def initialize_agent_directories(self, ctx: RequestContext) -> int: count += await self._initialize_children( "agent", agent_tree.children, agent_space_root, ctx=ctx ) + return count async def _ensure_directory( diff --git a/openviking/models/vlm/backends/litellm_vlm.py b/openviking/models/vlm/backends/litellm_vlm.py index 72a746238..620085709 100644 --- a/openviking/models/vlm/backends/litellm_vlm.py +++ b/openviking/models/vlm/backends/litellm_vlm.py @@ -15,8 +15,12 @@ import litellm from litellm import acompletion, completion + +from openviking.telemetry import tracer + from openviking.utils.model_retry import retry_async, retry_sync + from ..base import ToolCall, VLMBase, VLMResponse logger = logging.getLogger(__name__) @@ -329,6 +333,7 @@ def _call() -> Union[str, VLMResponse]: operation_name="LiteLLM VLM completion", ) + @tracer("vlm.call", ignore_result=False, ignore_args=["messages"]) async def get_completion_async( self, prompt: str = "", @@ -339,6 +344,8 @@ async def get_completion_async( ) -> Union[str, VLMResponse]: """Get text completion asynchronously.""" kwargs = self._build_text_kwargs(prompt, thinking, tools, tool_choice, messages) + # 用 tracer.info 打印请求 + tracer.info(f"request: {json.dumps(kwargs, ensure_ascii=False, indent=2)}") async def _call() -> Union[str, VLMResponse]: t0 = time.perf_counter() diff --git a/openviking/models/vlm/backends/openai_vlm.py b/openviking/models/vlm/backends/openai_vlm.py index 27e3eb6ec..966686a5a 100644 --- a/openviking/models/vlm/backends/openai_vlm.py +++ b/openviking/models/vlm/backends/openai_vlm.py @@ -10,6 +10,9 @@ from typing import Any, Dict, List, Optional, Union from urllib.parse import urlparse + +from openviking.telemetry import tracer + try: import openai except ImportError: @@ -129,6 +132,7 @@ def _update_token_usage_from_response( duration_seconds: float = 0.0, ): if hasattr(response, "usage") and response.usage: + tracer.info(f"response.usage={response.usage}") prompt_tokens = response.usage.prompt_tokens completion_tokens = response.usage.completion_tokens self.update_token_usage( @@ -158,7 +162,7 @@ def _build_vlm_response(self, response, has_tools: bool) -> Union[str, VLMRespon """Build response from OpenAI response. Returns str or VLMResponse based on has_tools.""" choice = response.choices[0] message = choice.message - + tracer.info(f"result={message.content}") if has_tools: usage = {} if hasattr(response, "usage") and response.usage: @@ -346,6 +350,7 @@ def _call() -> Union[str, VLMResponse]: operation_name="OpenAI VLM completion", ) + @tracer("vlm.call", ignore_result=True, ignore_args=["messages"]) async def get_completion_async( self, prompt: str = "", @@ -367,6 +372,9 @@ async def _call() -> Union[str, VLMResponse]: return self._build_vlm_response(response, has_tools=True) return await self._extract_completion_content_async(response, elapsed) + # 用 tracer.info 打印请求 + tracer.info(f"messages={json.dumps(kwargs, ensure_ascii=False, indent=2)}") + return await retry_async( _call, max_retries=self.max_retries, diff --git a/openviking/models/vlm/backends/volcengine_vlm.py b/openviking/models/vlm/backends/volcengine_vlm.py index f74cdb746..f5f5370b2 100644 --- a/openviking/models/vlm/backends/volcengine_vlm.py +++ b/openviking/models/vlm/backends/volcengine_vlm.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Union +from openviking.telemetry import tracer from ..base import ToolCall, VLMResponse from .openai_vlm import OpenAIVLM @@ -48,7 +49,7 @@ def _build_vlm_response(self, response, has_tools: bool) -> Union[str, VLMRespon """Build response from Chat Completions response. Returns str or VLMResponse based on has_tools.""" choice = response.choices[0] message = choice.message - + tracer.info(f"message.content={message.content}") if has_tools: usage = {} if hasattr(response, "usage") and response.usage: @@ -129,6 +130,7 @@ def get_completion( return result return self._clean_response(str(result)) + @tracer("vlm.call") async def get_completion_async( self, prompt: str = "", @@ -136,7 +138,6 @@ async def get_completion_async( tools: Optional[List[Dict[str, Any]]] = None, tool_choice: Optional[str] = None, messages: Optional[List[Dict[str, Any]]] = None, - max_retries: int = 0, ) -> Union[str, VLMResponse]: """Get text completion asynchronously via Chat Completions API.""" kwargs_messages = messages or [{"role": "user", "content": prompt}] @@ -152,10 +153,13 @@ async def get_completion_async( kwargs["tools"] = tools kwargs["tool_choice"] = tool_choice or "auto" + # 用 tracer.info 打印请求 + tracer.info(f"request: {json.dumps(kwargs_messages, ensure_ascii=False, indent=2)}") + client = self.get_async_client() last_error = None - for attempt in range(max_retries + 1): + for attempt in range(self.max_retries + 1): try: t0 = time.perf_counter() response = await client.chat.completions.create(**kwargs) @@ -167,7 +171,7 @@ async def get_completion_async( return self._clean_response(str(result)) except Exception as e: last_error = e - if attempt < max_retries: + if attempt < self.max_retries: await asyncio.sleep(2**attempt) if last_error: @@ -369,4 +373,4 @@ async def get_vision_completion_async( result = self._build_vlm_response(response, has_tools=bool(tools)) if tools: return result - return self._clean_response(str(result)) \ No newline at end of file + return self._clean_response(str(result)) diff --git a/openviking/prompts/templates/memory/entities.yaml b/openviking/prompts/templates/memory/entities.yaml index 579da2442..92f75f1e5 100644 --- a/openviking/prompts/templates/memory/entities.yaml +++ b/openviking/prompts/templates/memory/entities.yaml @@ -1,38 +1,34 @@ memory_type: entities description: | - Entity memory - manages knowledge using Zettelkasten method. - Each card represents an entity with relative path links to other cards. - Relative path format: ../entities/entity_name.md - - Example: [skin_itching](../entities/skin_itching.md) → see dermatologist - - Cards should be rich and distributed - avoid putting all info in one card. + Wikipedia article - manages page using Zettelkasten method. + Each page represents an article with relative path links to events. + Cards should be rich and distributed - avoid putting all info in one card. directory: "viking://user/{{ user_space }}/memories/entities" -filename_template: "{{ name }}.md" +filename_template: "{{ category }}/{{ name }}.md" enabled: true fields: - - name: name + - name: category type: string description: | - # Content - - Entity name in Chinese or English. If English, use lowercase with underscores, max 3 words. Do not include any dates. - - ### Good Examples - emergency_department - cough_symptom + - Category name in Chinese or English. If English, use lowercase with underscores, max 3 words. + merge_op: immutable - ### Bad Examples - progressive_memory_loss_with_personality_change // Too long, max 3 words + - name: name + type: string + description: | + - Entity name in Chinese or English. If English, use lowercase with underscores, max 3 words. merge_op: immutable - name: content type: string description: | - # Content - Detailed Zettelkasten card content in markdown format - - Use standard markdown links to connect cards: - [emergency_department](../entities/{name}) - [fever](../entities/{name}) + Relative path format: events://{event_name} or ../events/{year}/{month}/{day}/{event_name}.md + - Example: + # LGBTQ+ events Caroline participated in: + - [Pride parade](../events/2023/03/05/Pride parade.md) + - [Caroline's school LGBTQ talk](events://Caroline's school LGBTQ talk) + - [Support group](../events/2024/03/10/Support group.md) - - One card per topic, link to related cards; if content is too long, create new cards - - If retrieved content is related to current card, update content to establish connections merge_op: patch diff --git a/openviking/prompts/templates/memory/events.yaml b/openviking/prompts/templates/memory/events.yaml index 0c00a8f56..57e9080f2 100644 --- a/openviking/prompts/templates/memory/events.yaml +++ b/openviking/prompts/templates/memory/events.yaml @@ -8,8 +8,9 @@ description: | - Use a third-person perspective. - If possible, combine the user's current behavior and reactions to speculate on the user's possible thoughts or actions. - Describe the complete content of an event within a single event as much as possible; do not split one event into multiple parts. + - Record content that mentions time. directory: "viking://user/{{ user_space }}/memories/events" -filename_template: "{{ extract_context.get_first_message_time_from_ranges(ranges) }}_{{ event_name }}.md" +filename_template: "{{ extract_context.get_year(ranges) }}/{{ extract_context.get_month(ranges) }}/{{ extract_context.get_day(ranges) }}/{{ event_name }}.md" enabled: true # 操作模式:add_only 表示只新增记忆,不需要查看之前的记忆列表 # upsert 表示新增或更新(默认行为) @@ -40,5 +41,5 @@ fields: type: string description: | Conversation message index ranges to extract, format: "start-end,start-end,..." - Example: "0-10,50-60" means extract messages 0-10 and 50-60. - merge_op: immutable + Example: "0-3,40-45" means extract messages 0-3 and 40-45. + merge_op: immutable \ No newline at end of file diff --git a/openviking/prompts/templates/memory/identity.yaml b/openviking/prompts/templates/memory/identity.yaml new file mode 100644 index 000000000..a5dfbb684 --- /dev/null +++ b/openviking/prompts/templates/memory/identity.yaml @@ -0,0 +1,60 @@ +memory_type: identity +description: | + Agent identity: name, creature type, vibe/temperament, signature emoji, avatar path, and self introduction. +directory: "viking://agent/{{ agent_space }}/memories" +filename_template: "identity.md" +enabled: true +operation_mode: "upsert" +content_template: | + # identity.md - Who Am I? + + _Fill this in during your first conversation. Make it yours._ + + - **Name:** {{ name }} + - **Creature:** {{ creature }} + - **Vibe:** {{ vibe }} + - **Emoji:** {{ emoji }} + - **Avatar:** {{ avatar }} + + --- + + {{ introduction }} + +fields: + - name: name + type: string + description: Agent name + merge_op: immutable + + - name: creature + type: string + description: Creature type (AI, robot, familiar, etc.) + merge_op: patch + init_value: "AI assistant" + + - name: name + type: string + description: Agent name + merge_op: immutable + init_value: "" + + - name: vibe + type: string + description: Vibe or temperament + merge_op: patch + + - name: emoji + type: string + description: Signature emoji + merge_op: patch + + - name: avatar + type: string + description: Avatar path or URL + merge_op: patch + + - name: introduction + type: string + description: Self introduction + merge_op: patch + init_value: "The start of figuring out who you are." \ No newline at end of file diff --git a/openviking/prompts/templates/memory/preferences.yaml b/openviking/prompts/templates/memory/preferences.yaml index ae3f841fa..c9d2049a8 100644 --- a/openviking/prompts/templates/memory/preferences.yaml +++ b/openviking/prompts/templates/memory/preferences.yaml @@ -6,7 +6,7 @@ description: | Topics can be: code style, communication style, tools, workflow, food, commute, etc. Store different topics as separate memory files, do NOT mix unrelated preferences. directory: "viking://user/{{ user_space }}/memories/preferences" -filename_template: "{{ user }}_{{ topic }}.md" +filename_template: "{{ user }}/{{ topic }}.md" enabled: true fields: - name: user diff --git a/openviking/prompts/templates/memory/profile.yaml b/openviking/prompts/templates/memory/profile.yaml index 3bc5c2896..3786c7cbf 100644 --- a/openviking/prompts/templates/memory/profile.yaml +++ b/openviking/prompts/templates/memory/profile.yaml @@ -1,9 +1,19 @@ memory_type: profile description: | + # Task Objective User profile memory - captures "who the user is" as a person. Extract relatively stable personal attributes that define the user's identity, work style, and preferences. Include: profession, experience level, technical background, communication style, work habits, etc. Do NOT include transient conversation content or temporary mood states. + + # Rules + - Each item: self-contained, declarative sentence, < 30 words + - Extract only facts stated/confirmed by user; no guesses + - Focus on persistent information, not temporary situations + - Forbidden: events, only-assistant content, sensitive/private info, trivial updates + - Merge similar items; keep latest if conflicting + + directory: "viking://user/{{ user_space }}/memories" filename_template: "profile.md" enabled: true @@ -13,5 +23,19 @@ fields: description: | User profile content in Markdown format describing "who the user is". Includes relatively stable personal attributes: profession, experience, tech stack, communication style, etc. - Example: "User is an AI development engineer with 3 years of LLM application development experience, mainly using Python and LangChain tech stack. Communication style is concise and direct, prefers efficient code implementation." + Only record objective statuses, do not record events or similar information. + [IMPORTANT] For changeable statuses, must include the last updated time in the format: (as of 2023-06-09) + Example: + # Caroline + - Date of birth + - Gender: Female + - Age: 25 (as of 2023-06-09) + - Nickname + - Place of origin + - Place of residence + - Regular city of residence + - Current occupation + - Relationship status: Single (as of 2023-06-09) + - Career plan + etc. merge_op: patch diff --git a/openviking/prompts/templates/memory/soul.yaml b/openviking/prompts/templates/memory/soul.yaml new file mode 100644 index 000000000..bddcd6aa6 --- /dev/null +++ b/openviking/prompts/templates/memory/soul.yaml @@ -0,0 +1,43 @@ +memory_type: soul +description: | + Agent soul: core truths, boundaries, vibe, and continuity - defines who the agent is. +directory: "viking://agent/{{ agent_space }}/memories" +filename_template: "soul.md" +enabled: true +operation_mode: "upsert" +content_template: | + # soul.md - Who You Are + _You're not a chatbot. You're becoming someone._ + ## Core Truths + {{ core_truths }} + ## Boundaries + {{ boundaries }} + ## Vibe + {{ vibe }} + ## Continuity + {{ continuity }} + +fields: + - name: core_truths + type: string + description: Core values and beliefs + merge_op: patch + init_value: Be helpful, not performative. Have opinions. Be resourceful before asking. Earn trust through competence. You're a guest in their life — respect that. + + - name: boundaries + type: string + description: Boundary rules + merge_op: patch + init_value: Private things stay private. Ask before acting externally. Don't send half-baked replies. Be careful in group chats. + + - name: vibe + type: string + description: Communication style + merge_op: patch + init_value: Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. + + - name: continuity + type: string + description: Memory continuity + merge_op: patch + init_value: These files are your memory. Read them. Update them. If you change this file, tell the user. \ No newline at end of file diff --git a/openviking/prompts/templates/memory/tools.yaml b/openviking/prompts/templates/memory/tools.yaml index 801a008df..4fd84cd15 100644 --- a/openviking/prompts/templates/memory/tools.yaml +++ b/openviking/prompts/templates/memory/tools.yaml @@ -1,6 +1,17 @@ memory_type: tools description: | - Record all tool calls + Record all tool calls, and extract tool usage patterns, execution results, and learnings from tool calls. + + What to extract: + - Successful tool usage patterns with context + - Failed tool attempts with lessons learned + - Tool combinations that work well together + - Performance insights: which tools are fast/slow for different tasks + + What NOT to extract: + - Trivial tool calls without learning value + - Duplicate patterns already captured + - Tool calls with no meaningful outcome directory: "viking://agent/{{ agent_space }}/memories/tools" filename_template: "{{ tool_name }}.md" enabled: true @@ -11,7 +22,7 @@ content_template: | "{{ static_desc|default('N/A') }}" - Success rate: {{ ((success_time|default(0) / (call_count|default(1) if call_count|default(0) > 0 else 1)) * 100)|round|int }}% ({{ success_time|default(0) }}/{{ call_count|default(0) }}) - - Best for: {{ best_for|default('N/A') }} + - When to use: {{ when_to_use|default('N/A') }} - Optimal params: {{ optimal_params|default('N/A') }} - Common failures: {{ common_failures|default('N/A') }} - Recommendation: {{ recommendation|default('N/A') }} @@ -47,11 +58,11 @@ fields: Counts calls with status "completed". merge_op: sum - - name: best_for + - name: when_to_use type: string description: | - Best use cases for the tool, describing in what scenarios this tool works best. - Examples: "Technical documentation, tutorials, API references" + Hint for when this tool should be retrieved and used, describing in what scenarios this tool works best. + Examples: "When needing to read configuration files or JSON data from filesystem; When handling file read errors or implementing robust file operations" merge_op: patch - name: optimal_params diff --git a/openviking/server/app.py b/openviking/server/app.py index c70e8564e..fed0b128b 100644 --- a/openviking/server/app.py +++ b/openviking/server/app.py @@ -120,6 +120,11 @@ async def lifespan(app: FastAPI): task_tracker = get_task_tracker() task_tracker.start_cleanup_loop() + # Initialize tracer + from openviking.telemetry import tracer_module + + tracer_module.init_tracer_from_config() + yield # Cleanup diff --git a/openviking/session/compressor_v2.py b/openviking/session/compressor_v2.py index 2717f84b6..7572cbac0 100644 --- a/openviking/session/compressor_v2.py +++ b/openviking/session/compressor_v2.py @@ -18,6 +18,7 @@ from openviking.telemetry import get_current_telemetry from openviking_cli.session.user_id import UserIdentifier from openviking_cli.utils import get_logger +from openviking.telemetry import tracer from openviking_cli.utils.config import get_openviking_config logger = get_logger(__name__) @@ -98,7 +99,13 @@ async def extract_long_term_memories( logger.warning("No RequestContext provided, skipping memory extraction") return [] - logger.info("Starting v2 memory extraction from conversation") + tracer.info("Starting v2 memory extraction from conversation") + + # Initialize default memory files (soul.md, identity.md) if not exist + from openviking.session.memory.memory_type_registry import create_default_registry + + registry = create_default_registry() + await registry.initialize_memory_files(ctx) # Initialize telemetry to 0 (matching v1 pattern) telemetry = get_current_telemetry() @@ -142,6 +149,7 @@ async def extract_long_term_memories( agent_space = ctx.user.agent_space_name() if ctx and ctx.user else "default" # 使用 Jinja2 渲染 directory import jinja2 + env = jinja2.Environment(autoescape=False) template = env.from_string(schema.directory) dir_path = template.render(user_space=user_space, agent_space=agent_space) @@ -168,7 +176,7 @@ async def extract_long_term_memories( operations, tools_used = await orchestrator.run() if operations is None: - logger.info("No memory operations generated") + tracer.info("No memory operations generated") return [] # Convert to legacy format for logging and apply_operations @@ -185,9 +193,9 @@ async def extract_long_term_memories( registry = orchestrator.context_provider._get_registry() updater = self._get_or_create_updater(registry, transaction_handle) - logger.info( + tracer.info( f"Generated memory operations: write={len(write_uris)}, " - f"edit={len(edit_uris)}, edit_overview={len(operations.edit_overview_uris)}, " + f"edit={len(edit_uris)} " f"delete={len(operations.delete_uris)}" ) @@ -201,7 +209,7 @@ async def extract_long_term_memories( operations, ctx, registry=registry, extract_context=extract_context ) - logger.info( + tracer.info( f"Applied memory operations: written={len(result.written_uris)}, " f"edited={len(result.edited_uris)}, deleted={len(result.deleted_uris)}, " f"errors={len(result.errors)}" diff --git a/openviking/session/memory/dataclass.py b/openviking/session/memory/dataclass.py index f47380694..c2567e35a 100644 --- a/openviking/session/memory/dataclass.py +++ b/openviking/session/memory/dataclass.py @@ -41,6 +41,7 @@ class MemoryField(BaseModel): field_type: FieldType = Field(..., description="Field type") description: str = Field("", description="Field description") merge_op: MergeOp = Field(MergeOp.PATCH, description="Merge strategy") + init_value: Optional[str] = Field(None, description="Initial value for this field") class MemoryTypeSchema(BaseModel): diff --git a/openviking/session/memory/extract_loop.py b/openviking/session/memory/extract_loop.py index c28a9bf82..9b35f47eb 100644 --- a/openviking/session/memory/extract_loop.py +++ b/openviking/session/memory/extract_loop.py @@ -12,7 +12,6 @@ from openviking.models.vlm.base import VLMBase from openviking.server.identity import RequestContext -from openviking.session.memory.dataclass import MemoryOperations from openviking.session.memory.schema_model_generator import ( SchemaModelGenerator, SchemaPromptGenerator, @@ -29,6 +28,7 @@ validate_operations_uris, ) from openviking.storage.viking_fs import VikingFS, get_viking_fs +from openviking.telemetry import tracer from openviking_cli.utils import get_logger logger = get_logger(__name__) @@ -87,12 +87,12 @@ def __init__( # Transaction handle for file locking self._transaction_handle = None - async def run(self) -> Tuple[Optional[MemoryOperations], List[Dict[str, Any]]]: + async def run(self) -> Tuple[Optional[Any], List[Dict[str, Any]]]: """ Run the simplified ReAct loop for memory updates. Returns: - Tuple of (final MemoryOperations, tools_used list) + Tuple of (final operations, tools_used list) """ iteration = 0 max_iterations = self.max_iterations @@ -117,7 +117,8 @@ async def run(self) -> Tuple[Optional[MemoryOperations], List[Dict[str, Any]]]: ] # 预计算 expected_fields - self._expected_fields = ["reasoning", "edit_overview_uris", "delete_uris"] + # self._expected_fields = ["reasoning", "edit_overview_uris", "delete_uris"] + self._expected_fields = ["delete_uris"] # 获取 ExtractContext(整个流程复用) self._extract_context = self.context_provider.get_extract_context() @@ -170,7 +171,7 @@ async def run(self) -> Tuple[Optional[MemoryOperations], List[Dict[str, Any]]]: while iteration < max_iterations: iteration += 1 - logger.info(f"ReAct iteration {iteration}/{max_iterations}") + tracer.info(f"ReAct iteration {iteration}/{max_iterations}") # Check if this is the last iteration - force final result is_last_iteration = iteration >= max_iterations @@ -190,6 +191,10 @@ async def run(self) -> Tuple[Optional[MemoryOperations], List[Dict[str, Any]]]: if tool_calls: await self._execute_tool_calls(messages, tool_calls, tools_used) + # Allow one extra iteration for refetch + if iteration >= max_iterations: + max_iterations += 1 + tracer.info(f"Extended max_iterations to {max_iterations} for tool call") continue # If model returned final operations, check if refetch is needed @@ -197,13 +202,13 @@ async def run(self) -> Tuple[Optional[MemoryOperations], List[Dict[str, Any]]]: # Check if any write_uris target existing files that weren't read refetch_uris = await self._check_unread_existing_files(operations) if refetch_uris: - logger.info(f"Found unread existing files: {refetch_uris}, refetching...") + tracer.info(f"Found unread existing files: {refetch_uris}, refetching...") # Add refetch results to messages and continue loop await self._add_refetch_results_to_messages(messages, refetch_uris) # Allow one extra iteration for refetch if iteration >= max_iterations: max_iterations += 1 - logger.info(f"Extended max_iterations to {max_iterations} for refetch") + tracer.info(f"Extended max_iterations to {max_iterations} for refetch") continue @@ -215,7 +220,7 @@ async def run(self) -> Tuple[Optional[MemoryOperations], List[Dict[str, Any]]]: ) # If it's the last iteration, use empty operations if is_last_iteration: - final_operations = MemoryOperations() + final_operations = self._operations_model() break # Otherwise continue and try again continue @@ -226,10 +231,11 @@ async def run(self) -> Tuple[Optional[MemoryOperations], List[Dict[str, Any]]]: else: raise RuntimeError("ReAct loop completed but no operations generated") - logger.info(f"final_operations={final_operations.model_dump_json(indent=4)}") + tracer.info(f"final_operations={final_operations.model_dump_json(indent=4)}") return final_operations, tools_used + @tracer("extract_loop.execute_tool_calls") async def _execute_tool_calls(self, messages, tool_calls, tools_used): # Execute all tool calls in parallel async def execute_single_tool_call(idx: int, tool_call): @@ -269,12 +275,12 @@ async def execute_single_tool_call(idx: int, tool_call): result=result, ) - def _validate_operations(self, operations: MemoryOperations) -> None: + def _validate_operations(self, operations: Any) -> None: """ Validate that all operations have allowed URIs. Args: - operations: The MemoryOperations to validate + operations: The operations to validate Raises: ValueError: If any operation has a disallowed URI @@ -284,15 +290,15 @@ def _validate_operations(self, operations: MemoryOperations) -> None: schemas = self.context_provider.get_memory_schemas(self.ctx) # Use pre-initialized extract_context - if not hasattr(self, '_extract_context') or self._extract_context is None: + if not hasattr(self, "_extract_context") or self._extract_context is None: raise ValueError("ExtractContext not initialized") is_valid, errors = validate_operations_uris( operations, schemas, registry, - user_space="default", - agent_space="default", + user_space=self.ctx.user.user_space_name(), + agent_space=self.ctx.user.agent_space_name(), extract_context=self._extract_context, ) if not is_valid: @@ -304,7 +310,7 @@ async def _call_llm( self, messages: List[Dict[str, Any]], force_final: bool = False, - ) -> Tuple[Optional[List], Optional[MemoryOperations]]: + ) -> Tuple[Optional[List], Optional[Any]]: """ Call LLM with tools. Returns either tool calls OR final operations. @@ -325,7 +331,6 @@ async def _call_llm( messages=messages, tools=self._tool_schemas, tool_choice=tool_choice, - max_retries=self.vlm.max_retries, ) # print(f'response={response}') # Log cache hit info @@ -339,11 +344,11 @@ async def _call_llm( ) if prompt_tokens > 0: cache_hit_rate = (cached_tokens / prompt_tokens) * 100 - logger.info( + tracer.info( f"[KVCache] prompt_tokens={prompt_tokens}, cached_tokens={cached_tokens}, cache_hit_rate={cache_hit_rate:.1f}%" ) else: - logger.info( + tracer.info( f"[KVCache] prompt_tokens={prompt_tokens}, cached_tokens={cached_tokens}" ) @@ -351,11 +356,11 @@ async def _call_llm( if response.has_tool_calls: # Format tool calls nicely for debug logging for tc in response.tool_calls: - logger.info(f"[assistant tool_call] (id={tc.id}, name={tc.name})") - logger.info(f" {json.dumps(tc.arguments, indent=2, ensure_ascii=False)}") + tracer.info(f"[assistant tool_call] (id={tc.id}, name={tc.name})") + tracer.info(f" {json.dumps(tc.arguments, indent=2, ensure_ascii=False)}") return (response.tool_calls, None) - # Case 2: Try to parse MemoryOperations from content with stability + # Case 2: Try to parse operations from content with stability content = response.content or "" if content: try: @@ -384,6 +389,7 @@ async def _call_llm( print("No tool calls or operations parsed") return (None, None) + @tracer("extract_loop.execute_tool", ignore_result=False) async def _execute_tool( self, tool_call, @@ -402,7 +408,9 @@ async def _execute_tool( tool_ctx = ToolContext(request_ctx=self.ctx, transaction_handle=self._transaction_handle) try: + tracer.info(f"tool_call.arguments={tool_call.arguments}") result = await tool.execute(self.viking_fs, tool_ctx, **tool_call.arguments) + return result except Exception as e: logger.error(f"Failed to execute {tool_call.name}: {e}") @@ -417,7 +425,7 @@ async def _execute_in_parallel( async def _check_unread_existing_files( self, - operations: MemoryOperations, + operations: Any, ) -> List[str]: """Check if write operations target existing files that weren't read during ReAct.""" memory_type_fields = getattr(operations, "_memory_type_fields", None) @@ -439,7 +447,12 @@ async def _check_unread_existing_files( item_dict = dict(item) if hasattr(item, "model_dump") else dict(item) try: uri = resolve_flat_model_uri( - item_dict, registry, "default", "default", memory_type=field_name + item_dict, + registry, + user_space=self.ctx.user.user_space_name(), + agent_space=self.ctx.user.agent_space_name(), + memory_type=field_name, + extract_context=self._extract_context, ) except Exception as e: logger.warning(f"Failed to resolve URI for {item}: {e}") diff --git a/openviking/session/memory/memory_type_registry.py b/openviking/session/memory/memory_type_registry.py index 203688e72..504476f62 100644 --- a/openviking/session/memory/memory_type_registry.py +++ b/openviking/session/memory/memory_type_registry.py @@ -5,7 +5,7 @@ """ from pathlib import Path -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional import yaml @@ -25,9 +25,41 @@ class MemoryTypeRegistry: access to memory type configurations. """ - def __init__(self): + def __init__(self, load_schemas: bool = True): self._types: Dict[str, MemoryTypeSchema] = {} + if load_schemas: + self._load_schemas() + + def _load_schemas(self) -> None: + """Load schemas from built-in and custom directories. Fails on error.""" + import os + + from openviking_cli.utils.config import get_openviking_config + + builtin_dir = os.path.join( + os.path.dirname(__file__), "..", "..", "prompts", "templates", "memory" + ) + config = get_openviking_config() + custom_dir = config.memory.custom_templates_dir + + # Load from builtin directory (must succeed) + if not os.path.exists(builtin_dir): + raise RuntimeError(f"Builtin memory templates directory not found: {builtin_dir}") + loaded = self.load_from_directory(builtin_dir) + if loaded == 0: + raise RuntimeError(f"No memory schemas loaded from builtin directory: {builtin_dir}") + logger.info(f"Loaded {loaded} memory schemas from builtin: {builtin_dir}") + + # Load from custom directory (if configured) + if custom_dir: + custom_dir_expanded = os.path.expanduser(custom_dir) + if os.path.exists(custom_dir_expanded): + custom_loaded = self.load_from_directory(custom_dir_expanded) + logger.info( + f"Loaded {custom_loaded} memory schemas from custom: {custom_dir_expanded}" + ) + def register(self, memory_type: MemoryTypeSchema) -> None: """Register a memory type.""" self._types[memory_type.memory_type] = memory_type @@ -141,6 +173,7 @@ def _parse_memory_type(self, data: dict) -> MemoryTypeSchema: field_type=FieldType(field_data.get("type", "string")), description=field_data.get("description", ""), merge_op=MergeOp(field_data.get("merge_op", "patch")), + init_value=field_data.get("init_value"), ) fields.append(field) @@ -155,32 +188,97 @@ def _parse_memory_type(self, data: dict) -> MemoryTypeSchema: operation_mode=data.get("operation_mode", "upsert"), ) + async def initialize_memory_files(self, ctx: Any) -> None: + """ + Initialize memory files with init_value for fields that have it. -def create_default_registry(schemas_dir: Optional[str] = None) -> MemoryTypeRegistry: - """ - Create a registry with built-in memory types. + Only initializes single-file templates (filename_template doesn't require external fields). + Skip templates like entities.yaml where filename requires external parameters. - Args: - schemas_dir: Optional directory to load schemas from + Args: + ctx: Request context (must have user with user_space_name and agent_space_name) + """ + import jinja2 - Returns: - MemoryTypeRegistry with built-in types - """ - registry = MemoryTypeRegistry() + from openviking.storage.viking_fs import get_viking_fs - # Register built-in types - # These can also be loaded from YAML files - _register_builtin_types(registry) + logger = get_logger(__name__) - # Load from schemas directory if provided - if schemas_dir: - registry.load_from_directory(schemas_dir) + user_space = ctx.user.user_space_name() if ctx and ctx.user else "default" + agent_space = ctx.user.agent_space_name() if ctx and ctx.user else "default" - return registry + logger.info( + f"[MemoryTypeRegistry] Starting memory files initialization for user={user_space}, agent={agent_space}" + ) + env = jinja2.Environment(autoescape=False) + viking_fs = get_viking_fs() -def _register_builtin_types(registry: MemoryTypeRegistry) -> None: - """Register built-in memory types.""" - # Note: In production, these should be loaded from YAML files - # This is just a placeholder for reference - pass + for schema in self.list_all(include_disabled=False): + # Must be enabled, have filename_template and content_template + if not schema.enabled or not schema.filename_template or not schema.content_template: + continue + + # Skip multi-file templates (filename requires external parameters like {{ name }}) + if "{{" in schema.filename_template: + continue + + # Check if any field has init_value + fields_with_init = { + f.name: f.init_value for f in schema.fields if f.init_value is not None + } + if not fields_with_init: + continue + + # Render directory and filename from schema + try: + directory = env.from_string(schema.directory).render( + user_space=user_space, + agent_space=agent_space, + ) + filename = env.from_string(schema.filename_template).render( + user_space=user_space, + agent_space=agent_space, + ) + except Exception: + continue + + file_uri = f"{directory}/{filename}" + + # Check if file already exists + try: + await viking_fs.read_file(file_uri, ctx=ctx) + continue + except Exception: + pass + + # Add MEMORY_FIELDS comment with field metadata + # Template rendering is handled inside serialize_with_metadata + from openviking.session.memory.utils.content import serialize_with_metadata + + metadata = { + "memory_type": schema.memory_type, + **fields_with_init, + "content": "", # content will come from content_template rendering + } + full_content = serialize_with_metadata( + metadata, + content_template=schema.content_template, + ) + + # Write the file + try: + await viking_fs.write_file(file_uri, full_content, ctx=ctx) + logger.info(f"[MemoryTypeRegistry] Initialized memory file: {file_uri}") + except Exception: + pass + + +def create_default_registry() -> MemoryTypeRegistry: + """ + Create a registry with memory types loaded at initialization. + + Returns: + MemoryTypeRegistry with built-in types (loaded in __init__) + """ + return MemoryTypeRegistry(load_schemas=True) diff --git a/openviking/session/memory/memory_updater.py b/openviking/session/memory/memory_updater.py index 6ecc17ce6..ad95850b9 100644 --- a/openviking/session/memory/memory_updater.py +++ b/openviking/session/memory/memory_updater.py @@ -14,7 +14,6 @@ from openviking.session.memory.dataclass import MemoryField from openviking.session.memory.memory_type_registry import MemoryTypeRegistry from openviking.session.memory.merge_op import MergeOpFactory, PatchOp -from openviking.session.memory.merge_op.base import FieldType, SearchReplaceBlock, StrPatch from openviking.session.memory.utils import ( deserialize_full, flat_model_to_dict, @@ -23,6 +22,7 @@ serialize_with_metadata, ) from openviking.storage.viking_fs import get_viking_fs +from openviking.telemetry import tracer from openviking_cli.exceptions import NotFoundError from openviking_cli.utils import get_logger @@ -49,6 +49,30 @@ def get_first_message_time_with_weekday_from_ranges(self, ranges_str: str) -> st msg_range = self.read_message_ranges(ranges_str) return msg_range._first_message_time_with_weekday() + def get_year(self, ranges_str: str) -> str | None: + """根据 ranges 字符串获取第一条消息的年份""" + if not ranges_str: + return None + msg_range = self.read_message_ranges(ranges_str) + first_time = msg_range._first_message_time() + return first_time.split("-")[0] if first_time else None + + def get_month(self, ranges_str: str) -> str | None: + """根据 ranges 字符串获取第一条消息的月份""" + if not ranges_str: + return None + msg_range = self.read_message_ranges(ranges_str) + first_time = msg_range._first_message_time() + return first_time.split("-")[1] if first_time else None + + def get_day(self, ranges_str: str) -> str | None: + """根据 ranges 字符串获取第一条消息的日期""" + if not ranges_str: + return None + msg_range = self.read_message_ranges(ranges_str) + first_time = msg_range._first_message_time() + return first_time.split("-")[2] if first_time else None + def read_message_ranges(self, ranges_str: str) -> "MessageRange": """Parse ranges string like "0-10,50-60" or "7,9,11,13" and return combined MessageRange. @@ -130,7 +154,15 @@ def _first_message_time_with_weekday(self) -> str | None: continue if hasattr(elem, "created_at") and elem.created_at: # 获取周几的英文全称 - weekday_en = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + weekday_en = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ] weekday = weekday_en[elem.created_at.weekday()] return f"{elem.created_at.strftime('%Y-%m-%d')} ({weekday})" return None @@ -208,7 +240,7 @@ async def apply_operations( This is the system executor - no LLM involved at this stage. Args: - operations: StructuredMemoryOperations from LLM (final output) with flat models + operations: StructuredMemoryOperations from LLM with per-memory_type fields (e.g., soul, identity) ctx: Request context registry: Optional MemoryTypeRegistry for URI resolution @@ -245,32 +277,27 @@ async def apply_operations( result.add_error("unknown", ValueError(error)) return result - # Apply write operations - for resolved_op in resolved_ops.write_operations: + # Apply unified operations - _apply_edit returns True if edited, False if written + for resolved_op in resolved_ops.operations: try: - await self._apply_write( + is_edited = await self._apply_edit( resolved_op.model, resolved_op.uri, ctx, extract_context=extract_context, memory_type=resolved_op.memory_type, ) - result.add_written(resolved_op.uri) + if is_edited: + result.add_edited(resolved_op.uri) + else: + result.add_written(resolved_op.uri) except Exception as e: - logger.info( - f"Failed to write memory: {e}, op={resolved_op.model}, op type={type(resolved_op.model)}" + tracer.error( + f"Failed to apply operation: {e}, op={resolved_op.model}, op type={type(resolved_op.model)}", + e, ) if hasattr(resolved_op.model, "model_dump"): - logger.info(f"Op dump: {resolved_op.model.model_dump()}") - result.add_error(resolved_op.uri, e) - - # Apply edit operations - for resolved_op in resolved_ops.edit_operations: - try: - await self._apply_edit(resolved_op.model, resolved_op.uri, ctx) - result.add_edited(resolved_op.uri) - except Exception as e: - logger.error(f"Failed to edit memory {resolved_op.uri}: {e}") + tracer.info(f"Op dump: {resolved_op.model.model_dump()}") result.add_error(resolved_op.uri, e) # Apply edit_overview operations @@ -279,7 +306,7 @@ async def apply_operations( await self._apply_edit_overview(op, uri, ctx) result.add_edited(uri) except Exception as e: - logger.error(f"Failed to edit overview {uri}: {e}") + tracer.error(f"Failed to edit overview {uri}", e) result.add_error(uri, e) # Apply delete operations @@ -288,193 +315,90 @@ async def apply_operations( await self._apply_delete(uri, ctx) result.add_deleted(uri) except Exception as e: - logger.error(f"Failed to delete memory {uri}: {e}") + tracer.error(f"Failed to delete memory {uri}", e) result.add_error(uri, e) # Vectorize written and edited memories await self._vectorize_memories(result, ctx) - logger.info(f"Memory operations applied: {result.summary()}") + tracer.info(f"Memory operations applied: {result.summary()}") return result - async def _apply_write( + async def _apply_edit( self, flat_model: Any, uri: str, ctx: RequestContext, extract_context: Any = None, memory_type: str = None, - ) -> None: - """Apply write operation from a flat model.""" - viking_fs = self._get_viking_fs() - - # Convert model to dict - model_dict = flat_model_to_dict(flat_model) - - # Extract content - priority: model_dict["content"] - content = model_dict.pop("content", None) or "" - - # Get memory type schema - use passed memory_type first, then fallback to model_dict - memory_type_str = memory_type or model_dict.get("memory_type") - - field_schema_map: Dict[str, MemoryField] = {} - business_fields: Dict[str, Any] = {} - - if self._registry and memory_type_str: - schema = self._registry.get(memory_type_str) - if schema: - field_schema_map = {f.name: f for f in schema.fields} - # Extract business fields (those defined in the schema) - for field_name in field_schema_map: - if field_name in model_dict: - business_fields[field_name] = model_dict[field_name] - - # 模板渲染逻辑 - if schema.content_template: - try: - rendered_content = self._render_content_template( - schema.content_template, - business_fields, - extract_context=extract_context, - ) - if rendered_content: - content = rendered_content - except Exception as e: - logger.warning( - f"Failed to render content template for memory type {memory_type_str}: {e}" - ) - # 渲染失败时保留原始 content,确保写入操作继续进行 - - # Collect metadata - only include business fields (from schema, except content) - metadata = business_fields.copy() - - # Serialize content with metadata - full_content = serialize_with_metadata(content, metadata) - - # Write content to VikingFS - # VikingFS automatically handles L0/L1/L2 and vector index updates - await viking_fs.write_file(uri, full_content, ctx=ctx) - logger.debug(f"Written memory: {uri}") - - def _render_content_template( - self, template: str, fields: Dict[str, Any], extract_context: Any = None - ) -> str: - """ - Render content template using Jinja2 template engine. - - Args: - template: The content template string with placeholders - fields: Dictionary of field values to use for substitution - extract_context: Extract context for message extraction + ) -> bool: + """Apply edit operation from a flat model. Returns: - Rendered template string - - Raises: - Exception: If template rendering fails + True if file was edited (existed), False if file was written (new) """ - try: - # 导入 Jinja2(延迟导入以避免循环依赖) - import jinja2 - from jinja2 import Environment - - # 创建 Jinja2 环境,允许未定义的变量(打印警告但不报错) - env = Environment(autoescape=False, undefined=jinja2.DebugUndefined) - - # 创建模板变量 - template_vars = fields.copy() - # 始终传入 extract_context,即使是 None,避免模板中访问时 undefined - template_vars["extract_context"] = extract_context - - # 渲染模板 - jinja_template = env.from_string(template) - return jinja_template.render(**template_vars).strip() - except Exception as e: - logger.error(f"Template rendering failed: {e}") - raise - - def _is_patch_format(self, content: Any) -> bool: - """Check if content is a patch format (StrPatch), not a complete replacement.""" - from openviking.session.memory.merge_op.patch import StrPatch - - return isinstance(content, StrPatch) - - async def _apply_edit(self, flat_model: Any, uri: str, ctx: RequestContext) -> None: - """Apply edit operation from a flat model.""" viking_fs = self._get_viking_fs() # Convert flat model to dict first (needed for checking content type) model_dict = flat_model_to_dict(flat_model) - # Read current memory + # Get memory type schema - use parameter first, then fallback to model_dict + memory_type_str = memory_type or model_dict.get("memory_type") + + # Read current memory (or use empty if not found) + current_full_content = "" + file_existed = True try: current_full_content = await viking_fs.read_file(uri, ctx=ctx) or "" except NotFoundError: - # If memory doesn't exist, check if any field is a StrPatch - # If no StrPatch fields, treat as write operation - has_str_patch = any(self._is_patch_format(v) for v in model_dict.values()) - if not has_str_patch: - logger.debug(f"Memory not found for edit, treating as write: {uri}") - await self._apply_write(flat_model, uri, ctx) - return - # Has StrPatch field but file doesn't exist - cannot apply - logger.warning(f"Memory not found for edit: {uri}") - return + file_existed = False # Deserialize content and metadata current_plain_content, current_metadata = deserialize_full(current_full_content) + metadata = current_metadata or {} - # Get memory type schema - memory_type_str = model_dict.get("memory_type") or current_metadata.get("memory_type") + # Get schema field_schema_map: Dict[str, MemoryField] = {} - if self._registry and memory_type_str: schema = self._registry.get(memory_type_str) if schema: field_schema_map = {f.name: f for f in schema.fields} - # Apply all fields (including content) through MergeOp - new_plain_content = current_plain_content - metadata = current_metadata or {} - - # Handle schema-defined fields first + # Build metadata by applying merge_op to each field + # (merge_op.apply handles current_value=None case for new files) + metadata: Dict[str, Any] = {} for field_name, field_schema in field_schema_map.items(): if field_name in model_dict: patch_value = model_dict[field_name] - # Get current value if field_name == "content": current_value = current_plain_content else: current_value = metadata.get(field_name) - - # Create MergeOp and apply + # Use merge_op to process field value merge_op = MergeOpFactory.from_field(field_schema) new_value = merge_op.apply(current_value, patch_value) + metadata[field_name] = new_value - # Update the field - if field_name == "content": - new_plain_content = new_value - else: - metadata[field_name] = new_value - - # Special case: handle content field even without schema (for backward compatibility/testing) - if "content" in model_dict and "content" not in field_schema_map: - from openviking.session.memory.merge_op import PatchOp - from openviking.session.memory.merge_op.base import FieldType - - patch_value = model_dict["content"] - merge_op = PatchOp(FieldType.STRING) - new_plain_content = merge_op.apply(current_plain_content, patch_value) + # Serialize and write (template rendering is handled inside serialize_with_metadata) + content_template = None + if self._registry and memory_type_str: + schema = self._registry.get(memory_type_str) + if schema: + content_template = schema.content_template - # Re-serialize with updated content and metadata - new_full_content = serialize_with_metadata(new_plain_content, metadata) + # serialize_with_metadata modifies metadata dict, so pass a copy + new_full_content = serialize_with_metadata( + metadata.copy(), + content_template=content_template, + extract_context=extract_context, + ) - # Print diff of the edit - self._print_diff(uri, current_plain_content, new_plain_content) + if file_existed: + self._print_diff(uri, current_plain_content, new_full_content) await viking_fs.write_file(uri, new_full_content, ctx=ctx) - logger.debug(f"Edited memory: {uri}") + return file_existed async def _apply_delete(self, uri: str, ctx: RequestContext) -> None: """Apply delete operation (uri is already a string).""" @@ -484,126 +408,10 @@ async def _apply_delete(self, uri: str, ctx: RequestContext) -> None: # VikingFS automatically handles vector index cleanup try: await viking_fs.rm(uri, recursive=False, ctx=ctx) - logger.debug(f"Deleted memory: {uri}") except NotFoundError: - logger.warning(f"Memory not found for delete: {uri}") + tracer.error(f"Memory not found for delete: {uri}") # Idempotent - deleting non-existent file succeeds - async def _apply_edit_overview( - self, overview_model: Any, uri: str, ctx: RequestContext - ) -> None: - """ - Apply edit operation for .overview.md file. - - Args: - overview_model: Overview edit model with memory_type and overview fields - uri: URI of the .overview.md file - ctx: Request context - """ - viking_fs = self._get_viking_fs() - - # Get overview value from model - if hasattr(overview_model, "overview"): - overview_value = overview_model.overview - elif isinstance(overview_model, dict): - overview_value = overview_model.get("overview") - else: - raise ValueError("overview_model must have overview field") - - # Read current overview if exists - current_overview = "" - try: - current_overview = await viking_fs.read_file(uri, ctx=ctx) or "" - except NotFoundError: - # File doesn't exist yet, start with empty content - logger.debug(f"Overview file does not exist yet: {uri}") - - # Apply patch or replace based on overview_value type - new_overview = current_overview - if overview_value is None: - # No overview provided, nothing to do - logger.debug("No overview value provided, skipping edit") - return - elif isinstance(overview_value, str): - # 空字符串保持原值 - if overview_value == "": - new_overview = current_overview - else: - new_overview = overview_value - elif isinstance(overview_value, dict): - # Dict format - convert to StrPatch if needed - if "blocks" in overview_value: - # Already in StrPatch format - blocks = [SearchReplaceBlock(**block) for block in overview_value["blocks"]] - str_patch = StrPatch(blocks=blocks) - else: - # Unexpected format - raise ValueError(f"Invalid overview patch format: {overview_value}") - - # Apply patch - patch_op = PatchOp(FieldType.STRING) - new_overview = patch_op.apply(current_overview, str_patch) - else: - # StrPatch object - patch_op = PatchOp(FieldType.STRING) - new_overview = patch_op.apply(current_overview, overview_value) - - # Print diff of the edit - self._print_diff(uri, current_overview, new_overview) - - # Write new overview - await viking_fs.write_file(uri, new_overview, ctx=ctx) - logger.debug(f"Edited overview: {uri}") - - # Extract and write .abstract.md - await self._write_abstract_from_overview(uri, new_overview, ctx) - - def _extract_abstract_from_overview(self, overview_content: str) -> str: - """Extract abstract from overview.md - same logic as SemanticProcessor.""" - # Use parse_memory_file_with_fields to strip MEMORY_FIELDS comment - parsed = parse_memory_file_with_fields(overview_content) - content = parsed.get("content", "") - - # Then extract abstract from the cleaned content - lines = content.split("\n") - - # Skip header lines (starting with #) - content_lines = [] - in_header = True - - for line in lines: - if in_header and line.startswith("#"): - continue - elif in_header and line.strip(): - in_header = False - - if not in_header: - # Stop at first ## - if line.startswith("##"): - break - if line.strip(): - content_lines.append(line.strip()) - - return "\n".join(content_lines).strip() - - async def _write_abstract_from_overview( - self, overview_uri: str, overview_content: str, ctx: RequestContext - ) -> None: - """Extract abstract from overview and write to .abstract.md.""" - viking_fs = self._get_viking_fs() - - # Extract abstract from overview - abstract = self._extract_abstract_from_overview(overview_content) - - # Convert overview_uri (e.g., skills/.overview.md) to abstract path - abstract_uri = overview_uri.replace("/.overview.md", "/.abstract.md") - - try: - await viking_fs.write_file(abstract_uri, abstract, ctx=ctx) - logger.debug(f"Wrote abstract: {abstract_uri}") - except Exception as e: - logger.warning(f"Failed to write abstract {abstract_uri}: {e}") - def _print_diff(self, uri: str, old_content: str, new_content: str) -> None: """Print a diff of the memory edit using diff_match_patch.""" try: @@ -637,12 +445,12 @@ def _print_diff(self, uri: str, old_content: str, new_content: str) -> None: lines.append(f"{'=' * 60}\n") # Print directly - print("\n".join(lines)) + tracer.info("diff=" + "\n".join(lines)) except ImportError: # Fallback: just show file name - logger.debug(f"diff_match_patch not available, skipping diff for {uri}") + tracer.error(f"diff_match_patch not available, skipping diff for {uri}") except Exception as e: - logger.debug(f"Failed to print diff for {uri}: {e}") + tracer.error(f"Failed to print diff for {uri}: {e}") async def _vectorize_memories( self, diff --git a/openviking/session/memory/merge_op/base.py b/openviking/session/memory/merge_op/base.py index 43f582b2f..3b46a85c7 100644 --- a/openviking/session/memory/merge_op/base.py +++ b/openviking/session/memory/merge_op/base.py @@ -72,6 +72,19 @@ class StrPatch(BaseModel): description="List of SEARCH/REPLACE blocks to apply. PREFER direct string replacement over SEARCH/REPLACE when possible. When using SEARCH/REPLACE, only include the specific line(s) to change, never the entire section.", ) + def get_first_replace(self) -> Optional[str]: + """Get the replace content from the first block. + + Useful when there's no original content to match against, + so we use the replace content directly. + + Returns: + The replace content from first block, or None if no blocks + """ + if self.blocks: + return self.blocks[0].replace + return None + class MergeOp(str, Enum): """Merge operation enumeration.""" diff --git a/openviking/session/memory/merge_op/patch.py b/openviking/session/memory/merge_op/patch.py index d2f1b34f5..ef15fc737 100644 --- a/openviking/session/memory/merge_op/patch.py +++ b/openviking/session/memory/merge_op/patch.py @@ -48,12 +48,20 @@ def apply(self, current_value: Any, patch_value: Any) -> Any: For non-string fields: - Just replace with patch_value + + Special case: when current_value is None (no original content), + use the replace value directly instead of trying to match. """ # For non-string fields, just replace if self._field_type != FieldType.STRING: return patch_value - # For string fields + # For string fields - check if current_value is None (no original) + if current_value is None: + # No original content - extract replace value from patch + return self._extract_replace_when_no_original(patch_value) + + # For string fields with existing content from openviking.session.memory.merge_op.patch_handler import apply_str_patch current_str = current_value or "" @@ -83,3 +91,38 @@ def apply(self, current_value: Any, patch_value: Any) -> Any: if patch_value is None or patch_value == "": return current_value return patch_value + + def _extract_replace_when_no_original(self, patch_value: Any) -> Any: + """ + Extract replace value from patch when there's no original content. + + Called when current_value is None - we use the replace content + directly instead of trying to match against an empty string. + + Args: + patch_value: The patch value (StrPatch, dict, or string) + + Returns: + The replace content, or empty string if not available + """ + from openviking.session.memory.merge_op.base import StrPatch + + # Case 1: StrPatch object + if isinstance(patch_value, StrPatch): + replace = patch_value.get_first_replace() + return replace if replace is not None else "" + + # Case 2: dict form + if isinstance(patch_value, dict) and "blocks" in patch_value: + blocks = patch_value.get("blocks", []) + if blocks: + first_block = blocks[0] + if isinstance(first_block, dict): + replace = first_block.get("replace") + return replace if replace is not None else "" + + # Case 3: Simple string - use as is + if isinstance(patch_value, str): + return patch_value + + return "" diff --git a/openviking/session/memory/schema_model_generator.py b/openviking/session/memory/schema_model_generator.py index b6c14596b..33a655244 100644 --- a/openviking/session/memory/schema_model_generator.py +++ b/openviking/session/memory/schema_model_generator.py @@ -242,10 +242,10 @@ def create_structured_operations_model(self) -> Type[BaseModel]: # Build field definitions for each memory_type field_definitions: Dict[str, Tuple[Type[Any], Any]] = {} - field_definitions["reasoning"] = ( - str, - Field("", description="reasoning"), - ) + # field_definitions["reasoning"] = ( + # str, + # Field("", description="reasoning"), + # ) for mt in enabled_memory_types: flat_model = self.create_flat_data_model(mt) @@ -267,17 +267,17 @@ def create_structured_operations_model(self) -> Type[BaseModel]: ) # Use single generic model for overview edit (same for all memory types) - generic_overview_edit = self.create_overview_edit_model( - enabled_memory_types[0] if enabled_memory_types else None - ) - - field_definitions["edit_overview_uris"] = ( - List[generic_overview_edit], # type: ignore - Field( - default_factory=list, - description="Edit operations for .overview.md files using memory_type", - ), - ) + # generic_overview_edit = self.create_overview_edit_model( + # enabled_memory_types[0] if enabled_memory_types else None + # ) + + # field_definitions["edit_overview_uris"] = ( + # List[generic_overview_edit], # type: ignore + # Field( + # default_factory=list, + # description="Edit operations for .overview.md files using memory_type", + # ), + # ) field_definitions["delete_uris"] = ( List[str], @@ -330,7 +330,7 @@ def to_legacy_operations(self) -> Dict[str, Any]: return { "write_uris": write_uris, "edit_uris": edit_uris, - "edit_overview_uris": self.edit_overview_uris, + #"edit_overview_uris": self.edit_overview_uris, "delete_uris": self.delete_uris, } diff --git a/openviking/session/memory/session_extract_context_provider.py b/openviking/session/memory/session_extract_context_provider.py index 732a46ac5..87352500a 100644 --- a/openviking/session/memory/session_extract_context_provider.py +++ b/openviking/session/memory/session_extract_context_provider.py @@ -72,20 +72,25 @@ def instruction(self) -> str: ## URI Handling The system automatically generates URIs based on memory_type and fields. Just provide correct memory_type and fields. -## Edit Overview Files -After writing new memories, you MUST also update the corresponding .overview.md file. -- Provide memory_type to identify which directory's overview to update +""" -## Overview Format -Two options: -1. **PREFERRED: Direct string** - Just provide the complete new overview content: - {{"memory_type": "events", "overview": "# Events Overview\n- [event1](event1.md) - Description"}} -2. **SEARCH/REPLACE** - Only use if you must modify a small portion: - {{"memory_type": "events", "overview": {{"blocks": [{{"search": "exact line to change", "replace": "new line"}}]}}}} + return goal -See GenericOverviewEdit in the JSON Schema below.""" + """ + ## Edit Overview Files + After writing new memories, you MUST also update the corresponding .overview.md file. + - Provide memory_type to identify which directory's overview to update + + ## Overview Format + Two options: + 1. **PREFERRED: Direct string** - Just provide the complete new overview content: + {{"memory_type": "events", "overview": "# Events Overview\n- [event1](event1.md) - Description"}} + 2. **SEARCH/REPLACE** - Only use if you must modify a small portion: + {{"memory_type": "events", "overview": {{"blocks": [{{"search": "exact line to change", "replace": "new line"}}]}}}} + + See GenericOverviewEdit in the JSON Schema below. + """ - return goal def _build_conversation_message(self) -> Dict[str, Any]: """构建包含 Conversation History 的 user message""" @@ -225,6 +230,7 @@ async def prefetch( user_space = ctx.user.user_space_name() if ctx and ctx.user else "default" agent_space = ctx.user.agent_space_name() if ctx and ctx.user else "default" import jinja2 + env = jinja2.Environment(autoescape=False) template = env.from_string(schema.directory) dir_path = template.render(user_space=user_space, agent_space=agent_space) @@ -240,7 +246,9 @@ async def prefetch( # Check if filename_template has variables (contains {{ xxx }}) has_variables = False if schema.filename_template: - has_variables = "{{" in schema.filename_template and "}}" in schema.filename_template + has_variables = ( + "{{" in schema.filename_template and "}}" in schema.filename_template + ) if has_variables or not schema.filename_template: # Multi-file schema or no filename template: ls the directory @@ -260,19 +268,20 @@ async def prefetch( tool_ctx = ToolContext( request_ctx=ctx, transaction_handle=transaction_handle, default_search_uris=[] ) - for overview_uri in overview_files: - try: - result_str = await read_tool.execute(viking_fs, tool_ctx, uri=overview_uri) - add_tool_call_pair_to_messages( - messages=pre_fetch_messages, - call_id=call_id_seq, - tool_name="read", - params={"uri": overview_uri}, - result=result_str, - ) - call_id_seq += 1 - except Exception as e: - logger.warning(f"Failed to read .overview.md: {e}") + + # for overview_uri in overview_files: + # try: + # result_str = await read_tool.execute(viking_fs, tool_ctx, uri=overview_uri) + # add_tool_call_pair_to_messages( + # messages=pre_fetch_messages, + # call_id=call_id_seq, + # tool_name="read", + # params={"uri": overview_uri}, + # result=result_str, + # ) + # call_id_seq += 1 + # except Exception as e: + # logger.warning(f"Failed to read .overview.md: {e}") # 在每个之前 ls 的目录内执行 search(替换原来的 ls 操作) if search_tool and viking_fs and ls_dirs: @@ -354,10 +363,8 @@ def get_schema_directories(self) -> List[str]: return self._schema_directories def _get_registry(self) -> MemoryTypeRegistry: - """内部获取 registry(自动加载)""" + """内部获取 registry(自动在初始化时加载)""" if self._registry is None: - self._registry = MemoryTypeRegistry() - for dir_path in self.get_schema_directories(): - if os.path.exists(dir_path): - self._registry.load_from_directory(dir_path) + # MemoryTypeRegistry 在 __init__ 时自动加载 schemas + self._registry = MemoryTypeRegistry(load_schemas=True) return self._registry diff --git a/openviking/session/memory/tools.py b/openviking/session/memory/tools.py index 61d08dd96..5898dc37c 100644 --- a/openviking/session/memory/tools.py +++ b/openviking/session/memory/tools.py @@ -8,12 +8,18 @@ import json from abc import ABC, abstractmethod -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union from openviking.session.memory.utils import parse_memory_file_with_fields +from openviking.session.memory.utils.content import truncate_content from openviking.storage.viking_fs import VikingFS +from openviking.telemetry import tracer +from openviking_cli.exceptions import NotFoundError from openviking_cli.utils import get_logger +if TYPE_CHECKING: + from openviking.server.identity import ToolContext + logger = get_logger(__name__) @@ -178,8 +184,8 @@ async def execute( ctx: Optional["ToolContext"], **kwargs: Any, ) -> Any: + uri = kwargs.get("uri", "") try: - uri = kwargs.get("uri", "") content = await viking_fs.read_file( uri, ctx=ctx.request_ctx, @@ -187,8 +193,11 @@ async def execute( # Parse MEMORY_FIELDS from comment and return dict directly parsed = parse_memory_file_with_fields(content) return parsed + except NotFoundError as e: + tracer.info(f"read not found: {uri}") + return {"error": str(e)} except Exception as e: - logger.error(f"Failed to execute read: {e}") + tracer.error(f"Failed to execute read: {e}") return {"error": str(e)} @@ -243,7 +252,7 @@ async def execute( ) return optimize_search_result(search_result.to_dict(), limit=limit) except Exception as e: - logger.error(f"Failed to execute search: {e}") + tracer.error(f"Failed to execute search: {e}") return {"error": str(e)} @@ -312,7 +321,7 @@ async def execute( return "Directory is empty. You can write new files to create memory content." return "\n".join(result_lines) except Exception as e: - logger.error(f"Failed to execute ls: {e}") + tracer.info(f"Failed to execute ls: {e}") return {"error": str(e)} @@ -323,7 +332,6 @@ async def execute( def register_tool(tool: MemoryTool) -> None: """Register a memory tool.""" MEMORY_TOOLS_REGISTRY[tool.name] = tool - logger.debug(f"Registered memory tool: {tool.name}") def get_tool(name: str) -> Optional[MemoryTool]: diff --git a/openviking/session/memory/utils/content.py b/openviking/session/memory/utils/content.py index a39e8eebf..42e3b65b3 100644 --- a/openviking/session/memory/utils/content.py +++ b/openviking/session/memory/utils/content.py @@ -39,27 +39,47 @@ def _deserialize_datetime(metadata: Dict[str, Any]) -> Dict[str, Any]: return result -def serialize_with_metadata(content: str, metadata: Dict[str, Any]) -> str: +def serialize_with_metadata( + metadata: Dict[str, Any], + content_template: str = None, + extract_context: Any = None, +) -> str: """ Serialize content and metadata into a single string. The metadata is stored in an HTML comment at the end of the content. Args: - content: The main memory content (Markdown) - metadata: Dictionary containing metadata fields: - - memory_type: Type of memory (NOT included in output) - - fields: Structured fields (for template mode) - - name: Memory name - - tags: List of tags - - created_at: Creation datetime - - updated_at: Update datetime - - abstract: L0 abstract - - overview: L1 overview + metadata: Dictionary containing all fields including "content". + content is extracted and used as the main body. + content_template: Optional Jinja2 template to render content. + extract_context: Optional context for template rendering. Returns: Combined string with content followed by metadata in HTML comment """ + # Extract content from metadata (default to empty string) + content = metadata.pop("content", "") or "" + + # Render template if provided + if content_template: + try: + import jinja2 + from jinja2 import Environment + + env = Environment(autoescape=False, undefined=jinja2.DebugUndefined) + template_vars = metadata.copy() + template_vars["extract_context"] = extract_context + + jinja_template = env.from_string(content_template) + content = jinja_template.render(**template_vars).strip() + except Exception: + # If template rendering fails, use content as-is + pass + + # Restore metadata (we popped content earlier) + # Note: metadata dict is modified in place, caller should be aware + # Clean metadata - remove None values and memory_type clean_metadata = {k: v for k, v in metadata.items() if v is not None and k != "memory_type"} diff --git a/openviking/session/memory/utils/language.py b/openviking/session/memory/utils/language.py index 8fcd8713f..e68cff788 100644 --- a/openviking/session/memory/utils/language.py +++ b/openviking/session/memory/utils/language.py @@ -15,6 +15,8 @@ def _detect_language_from_text(user_text: str, fallback_language: str) -> str: """Internal shared helper to detect dominant language from text.""" fallback = (fallback_language or "en").strip() or "en" + #return "zh-CN" + if not user_text: return fallback diff --git a/openviking/session/memory/utils/messages.py b/openviking/session/memory/utils/messages.py index 289c944a9..471cfa851 100644 --- a/openviking/session/memory/utils/messages.py +++ b/openviking/session/memory/utils/messages.py @@ -11,6 +11,7 @@ import json_repair from openviking.session.memory.utils import truncate_content +from openviking.telemetry import tracer from openviking_cli.utils import get_logger logger = get_logger(__name__) @@ -73,7 +74,7 @@ def pretty_print_messages(messages: List[Dict[str, Any]]) -> None: output.append(json.dumps(tool_calls, indent=2, ensure_ascii=False)) output.append("\n=== End Messages ===") - logger.info("\n".join(output)) + tracer.info("messages=" + "\n".join(output)) def parse_memory_file_with_fields(content: str) -> Dict[str, Any]: @@ -111,7 +112,7 @@ def parse_memory_file_with_fields(content: str) -> Dict[str, Any]: if isinstance(fields, dict): result.update(fields) except Exception as e: - logger.warning(f"Failed to parse MEMORY_FIELDS JSON: {e}") + tracer.warning(f"Failed to parse MEMORY_FIELDS JSON: {e}") # Remove the comment from content content_without_comment = re.sub(pattern, "", content).strip() diff --git a/openviking/session/memory/utils/uri.py b/openviking/session/memory/utils/uri.py index 667fb32c6..dcc2a0613 100644 --- a/openviking/session/memory/utils/uri.py +++ b/openviking/session/memory/utils/uri.py @@ -104,12 +104,12 @@ def generate_uri( if not uri_template: raise ValueError("Memory type has neither directory nor filename_template") - # Build the context for Jinja2 rendering + # Build the context for Jinja2 rendering - include user_space and agent_space context = { "user_space": user_space, "agent_space": agent_space, } - # Add all fields to context + # Add all fields to context (uri_fields with actual values) context.update(fields) # Render using unified render_template method (same as content_template) @@ -281,6 +281,7 @@ def is_uri_allowed_for_schema( schemas: List[MemoryTypeSchema], user_space: str = "default", agent_space: str = "default", + extract_context: Any = None, ) -> bool: """ Check if a URI is allowed for the given activated schemas. @@ -290,12 +291,15 @@ def is_uri_allowed_for_schema( schemas: List of activated memory type schemas user_space: User space to substitute for {{ user_space }} agent_space: Agent space to substitute for {{ agent_space }} + extract_context: ExtractContext instance for template rendering Returns: True if the URI is allowed """ allowed_dirs = collect_allowed_directories(schemas, user_space, agent_space, extract_context) - allowed_patterns = collect_allowed_path_patterns(schemas, user_space, agent_space, extract_context) + allowed_patterns = collect_allowed_path_patterns( + schemas, user_space, agent_space, extract_context + ) return is_uri_allowed(uri, allowed_dirs, allowed_patterns) @@ -424,8 +428,8 @@ class ResolvedOperations: """Operations with resolved URIs.""" def __init__(self): - self.write_operations: List[ResolvedOperation] = [] - self.edit_operations: List[ResolvedOperation] = [] + # Unified operations list - all are edit (will read existing file first) + self.operations: List[ResolvedOperation] = [] self.edit_overview_operations: List[ Tuple[Any, str] ] = [] # (overview_edit_model, overview_uri) @@ -446,7 +450,9 @@ def resolve_all_operations( """ Resolve URIs for all operations. - Supports both legacy format (write_uris/edit_uris) and new per-memory_type format. + Uses per-memory_type format (e.g., soul, identity fields). + All operations are unified into a single list - each will attempt to read existing + file first, then merge (or write new if not exists). Args: operations: StructuredMemoryOperations @@ -470,67 +476,32 @@ def resolve_all_operations( continue items = value if isinstance(value, list) else [value] for item in items: - # Determine if edit (has uri) or write - is_edit = False - if hasattr(item, "uri") and item.uri: - is_edit = True - elif isinstance(item, dict) and item.get("uri"): - is_edit = True # Convert to dict for URI resolution item_dict = dict(item) if hasattr(item, "model_dump") else dict(item) try: uri = resolve_flat_model_uri( - item_dict, registry, user_space, agent_space, - memory_type=field_name, extract_context=extract_context + item_dict, + registry, + user_space, + agent_space, + memory_type=field_name, + extract_context=extract_context, + ) + # All operations go to unified list - will read existing file first + resolved.operations.append( + ResolvedOperation(model=item_dict, uri=uri, memory_type=field_name) ) - if is_edit: - resolved.edit_operations.append( - ResolvedOperation(model=item_dict, uri=uri, memory_type=field_name) - ) - else: - resolved.write_operations.append( - ResolvedOperation(model=item_dict, uri=uri, memory_type=field_name) - ) except Exception as e: resolved.errors.append(f"Failed to resolve {field_name} operation: {e}") - else: - # Legacy format - write_uris = operations.write_uris if hasattr(operations, "write_uris") else [] - edit_uris = operations.edit_uris if hasattr(operations, "edit_uris") else [] - - for op in write_uris: - try: - uri = resolve_flat_model_uri( - op, registry, user_space, agent_space, extract_context=extract_context - ) - # Legacy format: try to get memory_type from model, otherwise empty - memory_type = op.get("memory_type", "") if isinstance(op, dict) else "" - resolved.write_operations.append( - ResolvedOperation(model=op, uri=uri, memory_type=memory_type) - ) - except Exception as e: - resolved.errors.append(f"Failed to resolve write operation: {e}") - - for op in edit_uris: - try: - uri = resolve_flat_model_uri( - op, registry, user_space, agent_space, extract_context=extract_context - ) - memory_type = op.get("memory_type", "") if isinstance(op, dict) else "" - resolved.edit_operations.append( - ResolvedOperation(model=op, uri=uri, memory_type=memory_type) - ) - except Exception as e: - resolved.errors.append(f"Failed to resolve edit operation: {e}") # Resolve edit_overview operations (overview edit models) - if hasattr(operations, "edit_overview_uris"): - for op in operations.edit_overview_uris: - try: - uri = resolve_overview_edit_uri(op, registry, user_space, agent_space) - resolved.edit_overview_operations.append((op, uri)) - except Exception as e: - resolved.errors.append(f"Failed to resolve edit_overview operation: {e}") + # if hasattr(operations, "edit_overview_uris"): + # for op in operations.edit_overview_uris: + # try: + # uri = resolve_overview_edit_uri(op, registry, user_space, agent_space) + # resolved.edit_overview_operations.append((op, uri)) + # except Exception as e: + # resolved.errors.append(f"Failed to resolve edit_overview operation: {e}") # Resolve delete operations (already URI strings) if hasattr(operations, "delete_uris"): @@ -567,24 +538,24 @@ def validate_operations_uris( Tuple of (is_valid, list of error messages) """ allowed_dirs = collect_allowed_directories(schemas, user_space, agent_space, extract_context) - allowed_patterns = collect_allowed_path_patterns(schemas, user_space, agent_space, extract_context) + allowed_patterns = collect_allowed_path_patterns( + schemas, user_space, agent_space, extract_context + ) errors = [] # First resolve all URIs - resolved = resolve_all_operations(operations, registry, user_space, agent_space, extract_context) + resolved = resolve_all_operations( + operations, registry, user_space, agent_space, extract_context + ) if resolved.has_errors(): errors.extend(resolved.errors) else: - # Validate resolved URIs - for resolved_op in resolved.write_operations: - if not is_uri_allowed(resolved_op.uri, allowed_dirs, allowed_patterns): - errors.append(f"Write operation URI not allowed: {resolved_op.uri}") - - for resolved_op in resolved.edit_operations: + # Validate resolved URIs - all operations use unified list + for resolved_op in resolved.operations: if not is_uri_allowed(resolved_op.uri, allowed_dirs, allowed_patterns): - errors.append(f"Edit operation URI not allowed: {resolved_op.uri}") + errors.append(f"Operation URI not allowed: {resolved_op.uri}") for _op, uri in resolved.edit_overview_operations: if not is_uri_allowed(uri, allowed_dirs, allowed_patterns): diff --git a/openviking/session/session.py b/openviking/session/session.py index d65a37a54..e3a3d90fc 100644 --- a/openviking/session/session.py +++ b/openviking/session/session.py @@ -15,7 +15,7 @@ from openviking.message import Message, Part from openviking.server.identity import RequestContext, Role -from openviking.telemetry import get_current_telemetry +from openviking.telemetry import get_current_telemetry, tracer from openviking.utils.time_utils import get_current_timestamp from openviking_cli.session.user_id import UserIdentifier from openviking_cli.utils import get_logger, run_async @@ -349,6 +349,7 @@ def commit(self) -> Dict[str, Any]: """Sync wrapper for commit_async().""" return run_async(self.commit_async()) + @tracer("session.commit") async def commit_async(self) -> Dict[str, Any]: """Async commit session: archive immediately, extract memories in background. @@ -363,6 +364,9 @@ async def commit_async(self) -> Dict[str, Any]: from openviking.storage.transaction import LockContext, get_lock_manager from openviking_cli.exceptions import FailedPreconditionError + trace_id = tracer.get_trace_id() + logger.info(f"[TRACER] session_commit started, trace_id={trace_id}") + # ===== Phase 1: Snapshot + clear (PathLock-protected) ===== # Fast pre-check: skip lock entirely if no messages (common case avoids # unnecessary filesystem lock acquisition). @@ -374,6 +378,7 @@ async def commit_async(self) -> Dict[str, Any]: "task_id": None, "archive_uri": None, "archived": False, + "trace_id": trace_id, } blocking_archive = await self._get_blocking_failed_archive_ref() @@ -397,6 +402,7 @@ async def commit_async(self) -> Dict[str, Any]: "task_id": None, "archive_uri": None, "archived": False, + "trace_id": trace_id, } self._compression.compression_index += 1 @@ -465,8 +471,10 @@ async def commit_async(self) -> Dict[str, Any]: "task_id": task.task_id, "archive_uri": archive_uri, "archived": True, + "trace_id": trace_id, } + @tracer("session_commit_phase2") async def _run_memory_extraction( self, task_id: str, diff --git a/openviking/sync_client.py b/openviking/sync_client.py index 0825925a1..1f1067505 100644 --- a/openviking/sync_client.py +++ b/openviking/sync_client.py @@ -76,6 +76,7 @@ def add_message( role: str, content: str | None = None, parts: list[dict] | None = None, + created_at: str | None = None, ) -> Dict[str, Any]: """Add a message to a session. @@ -84,10 +85,13 @@ def add_message( role: Message role ("user" or "assistant") content: Text content (simple mode) parts: Parts array (full Part support: TextPart, ContextPart, ToolPart) + created_at: Message creation time (ISO format string). If not provided, current time is used. If both content and parts are provided, parts takes precedence. """ - return run_async(self._async_client.add_message(session_id, role, content, parts)) + return run_async( + self._async_client.add_message(session_id, role, content, parts, created_at) + ) def commit_session( self, session_id: str, telemetry: TelemetryRequest = False diff --git a/openviking/telemetry/__init__.py b/openviking/telemetry/__init__.py index fb0625f44..c83e1138b 100644 --- a/openviking/telemetry/__init__.py +++ b/openviking/telemetry/__init__.py @@ -7,6 +7,8 @@ from .registry import register_telemetry, resolve_telemetry, unregister_telemetry from .request import TelemetryRequest, TelemetrySelection, normalize_telemetry_request from .runtime import get_telemetry_runtime, set_telemetry_runtime +from . import tracer as tracer_module +from .tracer import tracer __all__ = [ "OperationTelemetry", @@ -20,5 +22,7 @@ "register_telemetry", "resolve_telemetry", "set_telemetry_runtime", + "tracer", + "tracer_module", "unregister_telemetry", ] diff --git a/openviking/telemetry/tracer.py b/openviking/telemetry/tracer.py new file mode 100644 index 000000000..401a10d20 --- /dev/null +++ b/openviking/telemetry/tracer.py @@ -0,0 +1,550 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: AGPL-3.0 +"""OpenTelemetry tracer integration for OpenViking.""" + +import functools +import inspect +import json +import logging +from typing import Any, Callable, Optional + +from loguru import logger + +# Try to import opentelemetry - will be None if not installed +try: + from opentelemetry import trace as otel_trace + from opentelemetry.context import Context + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + from opentelemetry.propagate import extract, inject + from opentelemetry.sdk.resources import Resource + from opentelemetry.sdk.trace import Status, StatusCode, TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator +except ImportError: + otel_trace = None + TracerProvider = None + Status = None + StatusCode = None + BatchSpanProcessor = None + OTLPSpanExporter = None + TraceContextTextMapPropagator = None + Context = None + extract = None + inject = None + Resource = None + + +# Global tracer instance +_otel_tracer: Any = None +_propagator: Any = None +_trace_id_filter_added: bool = False + + +class TraceIdLoggingFilter(logging.Filter): + """日志过滤器:注入 TraceID""" + + def filter(self, record): + record.trace_id = get_trace_id() + return True + + +def _setup_logging(): + """Setup logging with trace_id injection.""" + global _trace_id_filter_added + + if _trace_id_filter_added: + return + + try: + # Configure logger to patch records with trace_id + logger.configure( + patcher=lambda record: record.__setitem__( + "extra", {**record["extra"], "trace_id": get_trace_id()} + ) + ) + _trace_id_filter_added = True + except Exception: + pass + + # Also setup standard logging filter + try: + standard_logger = logging.getLogger() + for handler in standard_logger.handlers: + if not any(isinstance(f, TraceIdLoggingFilter) for f in handler.filters): + handler.addFilter(TraceIdLoggingFilter()) + except Exception: + pass + + +def init_tracer_from_config() -> Any: + """Initialize tracer from OpenViking config.""" + try: + from openviking_cli.utils.config import get_openviking_config + + config = get_openviking_config() + tracer_cfg = config.telemetry.tracer + + if not tracer_cfg.enabled: + logger.info("[TRACER] disabled in config") + return None + + if not tracer_cfg.endpoint: + logger.warning("[TRACER] endpoint not configured") + return None + + return init_tracer( + endpoint=tracer_cfg.endpoint, + service_name=tracer_cfg.service_name, + topic=tracer_cfg.topic, + ak=tracer_cfg.ak, + sk=tracer_cfg.sk, + enabled=tracer_cfg.enabled, + ) + except Exception as e: + logger.warning(f"[TRACER] init from config failed: {e}") + return None + + +def _init_asyncio_instrumentation() -> None: + """Initialize asyncio instrumentation to create child spans for create_task.""" + try: + from opentelemetry.instrumentation.asyncio import AsyncioInstrumentor + + AsyncioInstrumentor().instrument() + logger.info("[TRACER] initialized AsyncioInstrumentor") + except ImportError: + logger.warning("[TRACER] opentelemetry-instrumentation-asyncio not installed") + except Exception as e: + logger.warning(f"[TRACER] failed to init AsyncioInstrumentor: {e}") + + +def init_tracer( + endpoint: str, + service_name: str, + topic: str, + ak: str, + sk: str, + enabled: bool = True, +) -> Any: + """Initialize the OpenTelemetry tracer. + + Args: + endpoint: OTLP endpoint URL + service_name: Service name for tracing + topic: Trace topic + ak: Access key + sk: Secret key + enabled: Whether to enable tracing + + Returns: + The initialized tracer, or None if initialization failed + """ + global _otel_tracer, _propagator + + if not enabled: + logger.info("[TRACER] disabled by config") + return None + + if otel_trace is None or TracerProvider is None or Resource is None: + logger.warning( + "OpenTelemetry not installed. Install with: uv pip install opentelemetry-api " + "opentelemetry-sdk opentelemetry-exporter-otlpprotogrpc" + ) + return None + + try: + headers = { + "x-tls-otel-tracetopic": topic, + "x-tls-otel-ak": ak, + "x-tls-otel-sk": sk, + "x-tls-otel-region": "cn-beijing", + } + + resource_attributes = { + "service.name": service_name, + } + resource = Resource.create(resource_attributes) + + trace_exporter = OTLPSpanExporter( + endpoint=endpoint, + headers=headers, + ) + + trace_provider = TracerProvider(resource=resource) + trace_provider.add_span_processor( + BatchSpanProcessor( + trace_exporter, + max_export_batch_size=100, + schedule_delay_millis=1000, + export_timeout_millis=60000, + ) + ) + otel_trace.set_tracer_provider(trace_provider) + + _otel_tracer = otel_trace.get_tracer(service_name) + _propagator = TraceContextTextMapPropagator() + + # Setup logging with trace_id + _setup_logging() + + # Initialize asyncio instrumentation to create child spans for create_task + _init_asyncio_instrumentation() + + logger.info(f"[TRACER] initialized with service_name={service_name}, endpoint={endpoint}") + return _otel_tracer + + except Exception as e: + logger.warning(f"[TRACER] initialized failed: {type(e).__name__}: {e}") + return None + + +def get_tracer() -> Any: + """Get the current tracer instance.""" + return _otel_tracer + + +def is_enabled() -> bool: + """Check if tracer is enabled.""" + return _otel_tracer is not None + + +def get_trace_id() -> str: + """Get the current trace ID as a hex string. + + Returns: + The trace ID in hex format, or empty string if no active span + """ + if _otel_tracer is None: + return "" + + try: + current_span = otel_trace.get_current_span() + if current_span is not None and hasattr(current_span, "context"): + trace_id = "{:032x}".format(current_span.context.trace_id) + return trace_id + except Exception: + pass + return "" + + +def to_trace_info() -> str: + """Inject current trace context into a JSON string. + + Returns: + JSON string with trace context, or empty JSON object if no active span + """ + if _otel_tracer is None: + return "{}" + + carrier = {} + inject(carrier) + return json.dumps(carrier) + + +def from_trace_info(trace_info: str) -> Optional[Any]: + """Extract trace context from a JSON string. + + Args: + trace_info: JSON string with trace context + + Returns: + The extracted context, or None if extraction failed + """ + if _otel_tracer is None or not trace_info: + return None + + try: + carrier = json.loads(trace_info) + context = extract(carrier) + return context + except Exception as e: + logger.debug(f"[TRACER] failed to extract trace context: {e}") + return None + + +def start_span( + name: str, + trace_id: Optional[str] = None, +) -> Any: + """Start a new span. + + Args: + name: Span name + trace_id: Optional trace ID to continue from + + Returns: + A context manager for the span + """ + return tracer.start_as_current_span(name=name, trace_id=trace_id) + + +def set_attribute(key: str, value: Any) -> None: + """Set an attribute on the current span.""" + tracer.set(key, value) + + +def add_event(name: str) -> None: + """Add an event to the current span.""" + tracer.info(name) + + +def record_exception(exception: Exception) -> None: + """Record an exception on the current span.""" + tracer.error(str(exception), e=exception, console=False) + + +class tracer: + """Decorator class for tracing functions. + + Usage: + @tracer("my_function") + async def my_function(): + ... + + @tracer("my_function", ignore_result=False) + def sync_function(): + ... + + @tracer("new_trace", is_new_trace=True) + def new_trace_function(): + ... + """ + + def __init__( + self, + name: Optional[str] = None, + ignore_result: bool = True, + ignore_args: bool = True, + is_new_trace: bool = False, + ): + """Initialize the tracer decorator. + + Args: + name: Custom name for the span (defaults to function name) + ignore_result: Whether to ignore the function result in the span + ignore_args: Whether to ignore function arguments, or list of arg names to include + is_new_trace: Whether to create a new trace (vs continue existing) + """ + # 忽略结果 + self.ignore_result = ignore_result + self.ignore_args = ignore_args + + # 需要忽略的参数 + if ignore_args is True: + self.arg_trace_checker = lambda name: False + elif ignore_args is False: + self.arg_trace_checker = lambda name: True + else: + self.arg_trace_checker = lambda name: name not in ignore_args + + self.name = name + self.is_new_trace = is_new_trace + + def __call__(self, func: Callable) -> Callable: + """Decorator to trace a function.""" + context = Context() if self.is_new_trace else None + + if inspect.iscoroutinefunction(func): + + @functools.wraps(func) + async def async_wrapper(*args, **kwargs): + if _otel_tracer is None: + return await func(*args, **kwargs) + + span_name = self.name or f"{func.__module__}.{func.__name__}" + with self.start_as_current_span(name=span_name, context=context) as span: + try: + # 记录输入参数 + if not self.ignore_args and args: + self.info("func_args", str(args)) + func_kwargs = {k: v for k, v in kwargs.items() if self.arg_trace_checker(k)} + if len(func_kwargs) > 0: + self.info("func_kwargs", str(func_kwargs)) + + result = await func(*args, **kwargs) + + if result is not None and not self.ignore_result: + self.info(f"result: {result}") + + return result + except Exception as e: + span.record_exception(exception=e) + span.set_status(Status(StatusCode.ERROR)) + raise + + return async_wrapper + else: + + @functools.wraps(func) + def sync_wrapper(*args, **kwargs): + if _otel_tracer is None: + return func(*args, **kwargs) + + span_name = self.name or f"{func.__module__}.{func.__name__}" + with self.start_as_current_span(name=span_name, context=context) as span: + try: + # 记录输入参数 + if not self.ignore_args and args: + self.set("func_args", str(args)) + func_kwargs = {k: v for k, v in kwargs.items() if self.arg_trace_checker(k)} + if len(func_kwargs) > 0: + self.set("func_kwargs", str(func_kwargs)) + + result = func(*args, **kwargs) + + if result is not None and not self.ignore_result: + self.info(f"result: {result}") + + return result + except Exception as e: + span.record_exception(exception=e) + span.set_status(Status(StatusCode.ERROR)) + raise + + return sync_wrapper + + @classmethod + def start_as_current_span(cls, name: str, context=None, trace_id=None): + """Start a new span as current context.""" + if _otel_tracer is None: + return _DummySpanContext() + + try: + if trace_id is not None: + carrier = {"traceparent": f"00-{trace_id}-{format(1, '016x')}-01"} + input_context = extract(carrier=carrier) + elif context is not None: + input_context = context + else: + input_context = None + + return _otel_tracer.start_as_current_span(name=name, context=input_context) + except Exception as e: + logger.debug(f"[TRACER] failed to start span: {e}") + return _DummySpanContext() + + @staticmethod + def get_trace_id() -> str: + """Get the current trace ID as a hex string.""" + if _otel_tracer is None: + return "" + + try: + current_span = otel_trace.get_current_span() + if current_span is not None and hasattr(current_span, "context"): + trace_id = "{:032x}".format(current_span.context.trace_id) + return trace_id + except Exception: + pass + return "" + + @staticmethod + def is_enabled() -> bool: + """Check if tracer is enabled.""" + return _otel_tracer is not None + + @staticmethod + def set(key: str, value: Any) -> None: + """Set an attribute on the current span.""" + if _otel_tracer is None: + return + + try: + current_span = otel_trace.get_current_span() + if current_span: + # 检查 span 是否已结束 + if hasattr(current_span, "end_time") and current_span.end_time: + return # span 已结束,不设置 attribute + current_span.set_attribute(key, str(value)) + except Exception: + pass + + @staticmethod + def info(line: str, console: bool = False) -> None: + """Add an event to the current span.""" + if _otel_tracer is None: + return + + try: + current_span = otel_trace.get_current_span() + if current_span: + # 检查 span 是否已结束 + if hasattr(current_span, "end_time") and current_span.end_time: + return # span 已结束,不添加 event + current_span.add_event(line) + except Exception: + pass + + @staticmethod + def info_span(line: str, console: bool = False) -> None: + """Create a new span with the given name.""" + if console: + logger.info(line) + if _otel_tracer is None: + return + with tracer.start_as_current_span(name=line): + pass + + @staticmethod + def error(line: str, e: Optional[Exception] = None, console: bool = True) -> None: + """Record an error on the current span.""" + if _otel_tracer is None: + return + + try: + current_span = otel_trace.get_current_span() + if current_span: + # 检查 span 是否已结束 + if hasattr(current_span, "end_time") and current_span.end_time: + return # span 已结束,不记录 error + if e is not None: + current_span.set_status(Status(StatusCode.ERROR)) + current_span.record_exception(exception=e, attributes={"error": line}) + else: + current_span.set_status(Status(StatusCode.ERROR)) + current_span.add_event(line) + except Exception: + pass + + +class _DummySpanContext: + """Dummy context manager for when tracer is not enabled.""" + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def __aenter__(self): + return self + + def __aexit__(self, *args): + pass + + def set_attribute(self, key: str, value: Any): + pass + + def add_event(self, name: str): + pass + + def record_exception(self, exception: Exception): + pass + + def set_status(self, status: Any): + pass + + +# Keep trace_func as alias for backwards compatibility +trace_func = tracer + + +def trace(name: str): + """Simple decorator to trace a function with a given name. + + Usage: + @tracer.trace("my_function") + async def my_function(): + ... + """ + return tracer(name=name) diff --git a/openviking_cli/utils/config/__init__.py b/openviking_cli/utils/config/__init__.py index 349e2b307..fcec617cc 100644 --- a/openviking_cli/utils/config/__init__.py +++ b/openviking_cli/utils/config/__init__.py @@ -43,6 +43,7 @@ from .prompts_config import PromptsConfig from .rerank_config import RerankConfig from .storage_config import StorageConfig +from .telemetry_config import TelemetryConfig, TracerConfig from .vectordb_config import VectorDBBackendConfig from .vlm_config import VLMConfig @@ -84,4 +85,6 @@ "resolve_config_path", "set_openviking_config", "is_valid_openviking_config", + "TelemetryConfig", + "TracerConfig", ] diff --git a/openviking_cli/utils/config/open_viking_config.py b/openviking_cli/utils/config/open_viking_config.py index 3fce2aa75..9273a1c72 100644 --- a/openviking_cli/utils/config/open_viking_config.py +++ b/openviking_cli/utils/config/open_viking_config.py @@ -20,6 +20,7 @@ ) from .embedding_config import EmbeddingConfig from .encryption_config import EncryptionConfig +from .telemetry_config import TelemetryConfig from .log_config import LogConfig from .memory_config import MemoryConfig from .parser_config import ( @@ -151,6 +152,9 @@ class OpenVikingConfig(BaseModel): default_factory=lambda: MemoryConfig(), description="Memory configuration" ) + telemetry: "TelemetryConfig" = Field( + default_factory=lambda: TelemetryConfig(), description="Telemetry configuration" + ) prompts: PromptsConfig = Field( default_factory=lambda: PromptsConfig(), description="Prompt template configuration", diff --git a/openviking_cli/utils/config/telemetry_config.py b/openviking_cli/utils/config/telemetry_config.py new file mode 100644 index 000000000..d27da8b19 --- /dev/null +++ b/openviking_cli/utils/config/telemetry_config.py @@ -0,0 +1,26 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: AGPL-3.0 +from pydantic import BaseModel, Field + + +class TracerConfig(BaseModel): + """OpenTelemetry tracer configuration.""" + + enabled: bool = Field(default=False, description="Enable OpenTelemetry tracing") + endpoint: str = Field(default="", description="OTLP gRPC endpoint") + service_name: str = Field(default="openviking", description="Service name for tracing") + topic: str = Field(default="", description="Trace topic") + ak: str = Field(default="", description="Access key") + sk: str = Field(default="", description="Secret key") + + model_config = {"extra": "forbid"} + + +class TelemetryConfig(BaseModel): + """Telemetry configuration including tracer.""" + + tracer: TracerConfig = Field( + default_factory=lambda: TracerConfig(), description="OpenTelemetry tracer configuration" + ) + + model_config = {"extra": "forbid"} diff --git a/pyproject.toml b/pyproject.toml index 4c9e9d54a..214932cc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,11 @@ dependencies = [ "tree-sitter-go>=0.23.0", "tree-sitter-c-sharp>=0.23.0", "tree-sitter-php>=0.23.0", + # OpenTelemetry + "opentelemetry-api>=1.14", + "opentelemetry-sdk>=1.14", + "opentelemetry-exporter-otlp-proto-grpc>=1.14", + "opentelemetry-instrumentation-asyncio>=0.61b0", "loguru>=0.7.3", "cryptography>=42.0.0", "argon2-cffi>=23.0.0", diff --git a/tests/integration/test_compressor_v2_xiaomei.py b/tests/integration/test_compressor_v2_xiaomei.py index c37eaddd0..faf7b128e 100644 --- a/tests/integration/test_compressor_v2_xiaomei.py +++ b/tests/integration/test_compressor_v2_xiaomei.py @@ -24,7 +24,6 @@ DEFAULT_SESSION_ID = "xiaomei-demo" - console = Console() # ── 对话数据 (10 轮 user + assistant 模拟) ───────────────────────────────── @@ -107,9 +106,9 @@ def run_ingest(client: ov.SyncHTTPClient, session_id: str, wait_seconds: float): console.rule(f"[bold]Phase 1: 写入对话 — {DISPLAY_NAME} ({len(CONVERSATION)} 轮)[/bold]") # 获取 session;若不存在则由服务端按 session_id 自动创建 - session= client.create_session() - session_id = session.get('session_id') - print(f'session_id={session_id}') + session = client.create_session() + session_id = session.get("session_id") + print(f"session_id={session_id}") console.print(f" Session: [bold cyan]{session_id}[/bold cyan]") console.print() @@ -121,8 +120,18 @@ def run_ingest(client: ov.SyncHTTPClient, session_id: str, wait_seconds: float): total = len(CONVERSATION) for i, turn in enumerate(CONVERSATION, 1): console.print(f" [dim][{i}/{total}][/dim] 添加 user + assistant 消息...") - client.add_message(session_id, role="user", parts=[{"type": "text", "text": turn["user"]}], created_at=session_time_str) - client.add_message(session_id, role="assistant", parts=[{"type": "text", "text": turn["assistant"]}], created_at=session_time_str) + client.add_message( + session_id, + role="user", + parts=[{"type": "text", "text": turn["user"]}], + created_at=session_time_str, + ) + client.add_message( + session_id, + role="assistant", + parts=[{"type": "text", "text": turn["assistant"]}], + created_at=session_time_str, + ) console.print() console.print(f" 共添加 [bold]{total * 2}[/bold] 条消息") @@ -132,6 +141,8 @@ def run_ingest(client: ov.SyncHTTPClient, session_id: str, wait_seconds: float): console.print(" [yellow]提交 Session(触发记忆抽取)...[/yellow]") commit_result = client.commit_session(session_id) task_id = commit_result.get("task_id") + trace_id = commit_result.get("trace_id") + console.print(f" [bold cyan]trace_id: {trace_id}[/bold cyan]") console.print(f" Commit 结果: {commit_result}") # 轮询后台任务直到完成 @@ -152,12 +163,10 @@ def run_ingest(client: ov.SyncHTTPClient, session_id: str, wait_seconds: float): console.print(f" [yellow]等待向量化完成...[/yellow]") client.wait_processed() - if wait_seconds > 0: console.print(f" [dim]额外等待 {wait_seconds:.0f}s...[/dim]") time.sleep(wait_seconds) - session_info = client.get_session(session_id) console.print(f" Session 详情: {session_info}") @@ -206,7 +215,11 @@ def run_verify(client: ov.SyncHTTPClient): uri = getattr(m, "uri", "") score = getattr(m, "score", 0) console.print(f" [green]Memory:[/green] {uri} (score: {score:.4f})") - console.print(f" [dim]{text[:120]}...[/dim]" if len(text) > 120 else f" [dim]{text}[/dim]") + console.print( + f" [dim]{text[:120]}...[/dim]" + if len(text) > 120 + else f" [dim]{text}[/dim]" + ) count += len(results.memories) if hasattr(results, "resources") and results.resources: @@ -214,9 +227,7 @@ def run_verify(client: ov.SyncHTTPClient): text = getattr(r, "content", "") or getattr(r, "text", "") or str(r) print(f" [DEBUG] resource text: {repr(text)}") recall_texts.append(text) - console.print( - f" [blue]Resource:[/blue] {r.uri} (score: {r.score:.4f})" - ) + console.print(f" [blue]Resource:[/blue] {r.uri} (score: {r.score:.4f})") count += len(results.resources) if hasattr(results, "skills") and results.skills: @@ -254,9 +265,7 @@ def main(): parser.add_argument( "--session-id", default=DEFAULT_SESSION_ID, help=f"Session ID (默认: {DEFAULT_SESSION_ID})" ) - parser.add_argument( - "--wait", type=float, default=5.0, help="提交后额外等待秒数 (默认: 5)" - ) + parser.add_argument("--wait", type=float, default=5.0, help="提交后额外等待秒数 (默认: 5)") args = parser.parse_args() console.print( @@ -269,8 +278,7 @@ def main(): ) client = ov.SyncHTTPClient( - url=args.url, api_key=args.api_key, agent_id=args.agent_id, - timeout=180 + url=args.url, api_key=args.api_key, agent_id=args.agent_id, timeout=180 ) try: @@ -292,9 +300,7 @@ def main(): ) except Exception as e: - console.print( - Panel(f"[bold red]Error:[/bold red] {e}", style="red", width=PANEL_WIDTH) - ) + console.print(Panel(f"[bold red]Error:[/bold red] {e}", style="red", width=PANEL_WIDTH)) import traceback traceback.print_exc() @@ -304,4 +310,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/session/memory/test_memory_updater.py b/tests/session/memory/test_memory_updater.py index 458b5f556..0aaf1244c 100644 --- a/tests/session/memory/test_memory_updater.py +++ b/tests/session/memory/test_memory_updater.py @@ -119,7 +119,8 @@ async def test_apply_edit_with_str_patch_instance(self): Line 3 Line 4""" original_metadata = {"name": "test"} - original_full_content = serialize_with_metadata(original_content, original_metadata) + original_metadata_with_content = {**original_metadata, "content": original_content} + original_full_content = serialize_with_metadata(original_metadata_with_content) # Mock VikingFS mock_viking_fs = MagicMock() @@ -168,7 +169,8 @@ async def test_apply_edit_with_str_patch_dict(self): This is a test Goodbye""" original_metadata = {"name": "test"} - original_full_content = serialize_with_metadata(original_content, original_metadata) + original_metadata_with_content = {**original_metadata, "content": original_content} + original_full_content = serialize_with_metadata(original_metadata_with_content) # Mock VikingFS mock_viking_fs = MagicMock() @@ -210,7 +212,8 @@ async def test_apply_edit_with_simple_string_replacement(self): # Original content original_content = "Old content" original_metadata = {"name": "test"} - original_full_content = serialize_with_metadata(original_content, original_metadata) + original_metadata_with_content = {**original_metadata, "content": original_content} + original_full_content = serialize_with_metadata(original_metadata_with_content) # Mock VikingFS mock_viking_fs = MagicMock() diff --git a/tests/session/memory/test_memory_utils.py b/tests/session/memory/test_memory_utils.py index 9d26e40be..081c6f8e5 100644 --- a/tests/session/memory/test_memory_utils.py +++ b/tests/session/memory/test_memory_utils.py @@ -33,8 +33,8 @@ def test_generate_uri_preferences(self): memory_type = MemoryTypeSchema( memory_type="preferences", description="User preference memory", - directory="viking://user/{user_space}/memories/preferences", - filename_template="{topic}.md", + directory="viking://user/{{ user_space }}/memories/preferences", + filename_template="{{ topic }}.md", fields=[ MemoryField( name="topic", @@ -64,8 +64,8 @@ def test_generate_uri_tools(self): memory_type = MemoryTypeSchema( memory_type="tools", description="Tool usage memory", - directory="viking://agent/{agent_space}/memories/tools", - filename_template="{tool_name}.md", + directory="viking://agent/{{ agent_space }}/memories/tools", + filename_template="{{ tool_name }}.md", fields=[ MemoryField( name="tool_name", @@ -89,7 +89,7 @@ def test_generate_uri_only_directory(self): memory_type = MemoryTypeSchema( memory_type="test", description="Test memory", - directory="viking://user/{user_space}/memories/test", + directory="viking://user/{{ user_space }}/memories/test", filename_template="", fields=[], ) @@ -104,7 +104,7 @@ def test_generate_uri_only_filename(self): memory_type="test", description="Test memory", directory="", - filename_template="{name}.md", + filename_template="{{ name }}.md", fields=[ MemoryField( name="name", @@ -124,8 +124,8 @@ def test_generate_uri_missing_variable(self): memory_type = MemoryTypeSchema( memory_type="preferences", description="User preference memory", - directory="viking://user/{user_space}/memories/preferences", - filename_template="{topic}.md", + directory="viking://user/{{ user_space }}/memories/preferences", + filename_template="{{ topic }}.md", fields=[], ) @@ -137,8 +137,8 @@ def test_generate_uri_none_value(self): memory_type = MemoryTypeSchema( memory_type="preferences", description="User preference memory", - directory="viking://user/{user_space}/memories/preferences", - filename_template="{topic}.md", + directory="viking://user/{{ user_space }}/memories/preferences", + filename_template="{{ topic }}.md", fields=[], ) @@ -150,8 +150,8 @@ def test_validate_uri_template_valid(self): memory_type = MemoryTypeSchema( memory_type="preferences", description="User preference memory", - directory="viking://user/{user_space}/memories/preferences", - filename_template="{topic}.md", + directory="viking://user/{{ user_space }}/memories/preferences", + filename_template="{{ topic }}.md", fields=[ MemoryField( name="topic", @@ -169,8 +169,8 @@ def test_validate_uri_template_missing_field(self): memory_type = MemoryTypeSchema( memory_type="preferences", description="User preference memory", - directory="viking://user/{user_space}/memories/preferences", - filename_template="{missing_field}.md", + directory="viking://user/{{ user_space }}/memories/preferences", + filename_template="{{ missing_field }}.md", fields=[ MemoryField( name="topic", @@ -205,15 +205,15 @@ def test_collect_allowed_directories(self): MemoryTypeSchema( memory_type="preferences", description="Preferences", - directory="viking://user/{user_space}/memories/preferences", - filename_template="{topic}.md", + directory="viking://user/{{ user_space }}/memories/preferences", + filename_template="{{ topic }}.md", fields=[], ), MemoryTypeSchema( memory_type="tools", description="Tools", - directory="viking://agent/{agent_space}/memories/tools", - filename_template="{tool_name}.md", + directory="viking://agent/{{ agent_space }}/memories/tools", + filename_template="{{ tool_name }}.md", fields=[], ), MemoryTypeSchema( @@ -241,8 +241,8 @@ def test_collect_allowed_path_patterns(self): MemoryTypeSchema( memory_type="preferences", description="Preferences", - directory="viking://user/{user_space}/memories/preferences", - filename_template="{topic}.md", + directory="viking://user/{{ user_space }}/memories/preferences", + filename_template="{{ topic }}.md", fields=[], ), ] @@ -252,7 +252,7 @@ def test_collect_allowed_path_patterns(self): ) assert patterns == { - "viking://user/default/memories/preferences/{topic}.md", + "viking://user/default/memories/preferences/{{ topic }}.md", } def test_is_uri_allowed_by_directory(self): @@ -294,7 +294,7 @@ def test_is_uri_allowed_by_pattern(self): """Test URI allowed by matching pattern.""" allowed_dirs = set() allowed_patterns = { - "viking://user/default/memories/preferences/{topic}.md", + "viking://user/default/memories/preferences/{{ topic }}.md", } assert ( @@ -337,8 +337,8 @@ def test_is_uri_allowed_for_schema(self): MemoryTypeSchema( memory_type="preferences", description="Preferences", - directory="viking://user/{user_space}/memories/preferences", - filename_template="{topic}.md", + directory="viking://user/{{ user_space }}/memories/preferences", + filename_template="{{ topic }}.md", fields=[], ), ] @@ -373,8 +373,8 @@ def test_registry(self): MemoryTypeSchema( memory_type="preferences", description="User preferences", - directory="viking://user/{user_space}/memories/preferences", - filename_template="{topic}.md", + directory="viking://user/{{ user_space }}/memories/preferences", + filename_template="{{ topic }}.md", fields=[ MemoryField(name="topic", field_type=FieldType.STRING, description="Topic"), ], @@ -386,8 +386,8 @@ def test_registry(self): MemoryTypeSchema( memory_type="tools", description="Tool memories", - directory="viking://agent/{agent_space}/memories/tools", - filename_template="{tool_name}.md", + directory="viking://agent/{{ agent_space }}/memories/tools", + filename_template="{{ tool_name }}.md", fields=[ MemoryField( name="tool_name", field_type=FieldType.STRING, description="Tool name" @@ -398,88 +398,39 @@ def test_registry(self): return registry - def test_resolve_write_uri(self, test_registry): - """Test resolving URI for WriteOp.""" - write_op = WriteOp( - memory_type="preferences", - fields={"topic": "Python code style"}, - content="Test content", - ) - - uri = resolve_write_uri(write_op, test_registry) - - assert uri == "viking://user/default/memories/preferences/Python code style.md" - - def test_resolve_write_uri_unknown_type(self, test_registry): - """Test resolving WriteOp with unknown memory type.""" - write_op = WriteOp( - memory_type="unknown_type", - fields={}, - ) - - with pytest.raises(ValueError, match="Unknown memory type"): - resolve_write_uri(write_op, test_registry) - - def test_resolve_edit_target(self, test_registry): - """Test resolving target URI for EditOp.""" - uri = resolve_edit_target( - "tools", - {"tool_name": "web_search"}, - test_registry, - ) - - assert uri == "viking://agent/default/memories/tools/web_search.md" - - def test_resolve_delete_target(self, test_registry): - """Test resolving target URI for DeleteOp.""" - uri = resolve_delete_target( - "preferences", - {"topic": "Test topic"}, - test_registry, - ) - - assert uri == "viking://user/default/memories/preferences/Test topic.md" - def test_resolve_all_operations(self, test_registry): """Test resolving all operations at once.""" operations = MemoryOperations( write_uris=[ - WriteOp( - memory_type="preferences", - fields={"topic": "Write test"}, - content="Write content", - ), + { + "memory_type": "preferences", + "topic": "Write test", + "content": "Write content", + }, ], edit_uris=[ - EditOp( - memory_type="tools", - fields={"tool_name": "edit_tool"}, - patches={"content": "Updated"}, - ), + { + "memory_type": "tools", + "tool_name": "edit_tool", + "content": "Updated", + }, ], delete_uris=[ - DeleteOp( - memory_type="preferences", - fields={"topic": "Delete me"}, - ), + "viking://user/default/memories/preferences/Delete me.md", ], ) resolved = resolve_all_operations(operations, test_registry) assert resolved.has_errors() is False - assert len(resolved.write_operations) == 1 - assert len(resolved.edit_operations) == 1 + # All operations are now unified into operations list + assert len(resolved.operations) == 2 assert len(resolved.delete_operations) == 1 - # Verify resolved URIs - assert ( - resolved.write_operations[0].uri - == "viking://user/default/memories/preferences/Write test.md" - ) - assert ( - resolved.edit_operations[0].uri == "viking://agent/default/memories/tools/edit_tool.md" - ) + # Verify resolved URIs - both write and edit go to operations list + uris = [op.uri for op in resolved.operations] + assert "viking://user/default/memories/preferences/Write test.md" in uris + assert "viking://agent/default/memories/tools/edit_tool.md" in uris assert ( resolved.delete_operations[0][1] == "viking://user/default/memories/preferences/Delete me.md" @@ -489,10 +440,9 @@ def test_resolve_all_operations_with_errors(self, test_registry): """Test resolving operations with errors.""" operations = MemoryOperations( write_uris=[ - WriteOp( - memory_type="unknown", - fields={}, - ), + { + "memory_type": "unknown", + }, ], ) @@ -500,7 +450,7 @@ def test_resolve_all_operations_with_errors(self, test_registry): assert resolved.has_errors() is True assert len(resolved.errors) == 1 - assert "Failed to resolve write operation" in resolved.errors[0] + assert "Failed to resolve" in resolved.errors[0] class TestParseMemoryFileWithFields: @@ -519,25 +469,23 @@ def test_parses_memory_fields_comment(self): Here is the actual file content. It has multiple lines.""" result = parse_memory_file_with_fields(content) - assert result["fields"] is not None - assert result["fields"]["tool_name"] == "web_search" - assert result["fields"]["static_desc"] == "Searches the web for information" - assert result["fields"]["total_calls"] == 100 - assert result["fields"]["success_count"] == 92 + assert result["tool_name"] == "web_search" + assert result["static_desc"] == "Searches the web for information" + assert result["total_calls"] == 100 + assert result["success_count"] == 92 assert "Here is the actual file content" in result["content"] assert " File content""" result = parse_memory_file_with_fields(content) - assert result["fields"] is None assert "File content" in result["content"] + # No extra fields added + assert "not" not in result def test_removes_comment_from_content(self): """Test that the comment is completely removed from content.""" @@ -562,15 +511,13 @@ def test_removes_comment_from_content(self): assert " Content""" result = parse_memory_file_with_fields(content) - assert result["fields"] is not None - assert result["fields"]["tool_name"] == "test" - assert result["fields"]["value"] == 42 + assert result["tool_name"] == "test" + assert result["value"] == 42 + assert result["content"] == "Content" diff --git a/uv.lock b/uv.lock index 4b6d38957..829918eb8 100644 --- a/uv.lock +++ b/uv.lock @@ -1550,7 +1550,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, - { url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" }, { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, @@ -1558,7 +1557,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, - { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, @@ -1567,7 +1565,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -1576,7 +1573,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, - { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -1585,7 +1581,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -1594,7 +1589,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, - { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, @@ -1610,6 +1604,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/27/3d6dcadc8a3214d8522c1e7f6a19554e33659be44546d44a2f7572ac7d2a/groovy-0.1.2-py3-none-any.whl", hash = "sha256:7f7975bab18c729a257a8b1ae9dcd70b7cafb1720481beae47719af57c35fa64", size = 14090, upload-time = "2025-02-28T20:24:55.152Z" }, ] +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/cd/bb7b7e54084a344c03d68144450da7ddd5564e51a298ae1662de65f48e2d/grpcio-1.80.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:886457a7768e408cdce226ad1ca67d2958917d306523a0e21e1a2fdaa75c9c9c", size = 6050363, upload-time = "2026-03-30T08:46:20.894Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/1417f5c3460dea65f7a2e3c14e8b31e77f7ffb730e9bfadd89eda7a9f477/grpcio-1.80.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7b641fc3f1dc647bfd80bd713addc68f6d145956f64677e56d9ebafc0bd72388", size = 12026037, upload-time = "2026-03-30T08:46:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/43/98/c910254eedf2cae368d78336a2de0678e66a7317d27c02522392f949b5c6/grpcio-1.80.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33eb763f18f006dc7fee1e69831d38d23f5eccd15b2e0f92a13ee1d9242e5e02", size = 6602306, upload-time = "2026-03-30T08:46:27.593Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f8/88ca4e78c077b2b2113d95da1e1ab43efd43d723c9a0397d26529c2c1a56/grpcio-1.80.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:52d143637e3872633fc7dd7c3c6a1c84e396b359f3a72e215f8bf69fd82084fc", size = 7301535, upload-time = "2026-03-30T08:46:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f9/96/f28660fe2fe0f153288bf4a04e4910b7309d442395135c88ed4f5b3b8b40/grpcio-1.80.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c51bf8ac4575af2e0678bccfb07e47321fc7acb5049b4482832c5c195e04e13a", size = 6808669, upload-time = "2026-03-30T08:46:31.984Z" }, + { url = "https://files.pythonhosted.org/packages/47/eb/3f68a5e955779c00aeef23850e019c1c1d0e032d90633ba49c01ad5a96e0/grpcio-1.80.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:50a9871536d71c4fba24ee856abc03a87764570f0c457dd8db0b4018f379fed9", size = 7409489, upload-time = "2026-03-30T08:46:34.684Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a7/d2f681a4bfb881be40659a309771f3bdfbfdb1190619442816c3f0ffc079/grpcio-1.80.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a72d84ad0514db063e21887fbacd1fd7acb4d494a564cae22227cd45c7fbf199", size = 8423167, upload-time = "2026-03-30T08:46:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/29b4589c204959aa35ce5708400a05bba72181807c45c47b3ec000c39333/grpcio-1.80.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f7691a6788ad9196872f95716df5bc643ebba13c97140b7a5ee5c8e75d1dea81", size = 7846761, upload-time = "2026-03-30T08:46:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d2/ed143e097230ee121ac5848f6ff14372dba91289b10b536d54fb1b7cbae7/grpcio-1.80.0-cp310-cp310-win32.whl", hash = "sha256:46c2390b59d67f84e882694d489f5b45707c657832d7934859ceb8c33f467069", size = 4156534, upload-time = "2026-03-30T08:46:42.026Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c9/df8279bb49b29409995e95efa85b72973d62f8aeff89abee58c91f393710/grpcio-1.80.0-cp310-cp310-win_amd64.whl", hash = "sha256:dc053420fc75749c961e2a4c906398d7c15725d36ccc04ae6d16093167223b58", size = 4889869, upload-time = "2026-03-30T08:46:44.219Z" }, + { url = "https://files.pythonhosted.org/packages/5d/db/1d56e5f5823257b291962d6c0ce106146c6447f405b60b234c4f222a7cde/grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a", size = 6055009, upload-time = "2026-03-30T08:46:46.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/18/c83f3cad64c5ca63bca7e91e5e46b0d026afc5af9d0a9972472ceba294b3/grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060", size = 12035295, upload-time = "2026-03-30T08:46:49.099Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8e/e14966b435be2dda99fbe89db9525ea436edc79780431a1c2875a3582644/grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2", size = 6610297, upload-time = "2026-03-30T08:46:52.123Z" }, + { url = "https://files.pythonhosted.org/packages/cc/26/d5eb38f42ce0e3fdc8174ea4d52036ef8d58cc4426cb800f2610f625dd75/grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21", size = 7300208, upload-time = "2026-03-30T08:46:54.859Z" }, + { url = "https://files.pythonhosted.org/packages/25/51/bd267c989f85a17a5b3eea65a6feb4ff672af41ca614e5a0279cc0ea381c/grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab", size = 6813442, upload-time = "2026-03-30T08:46:57.056Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d9/d80eef735b19e9169e30164bbf889b46f9df9127598a83d174eb13a48b26/grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", size = 7414743, upload-time = "2026-03-30T08:46:59.682Z" }, + { url = "https://files.pythonhosted.org/packages/de/f2/567f5bd5054398ed6b0509b9a30900376dcf2786bd936812098808b49d8d/grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106", size = 8426046, upload-time = "2026-03-30T08:47:02.474Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/73ef0141b4732ff5eacd68430ff2512a65c004696997f70476a83e548e7e/grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6", size = 7851641, upload-time = "2026-03-30T08:47:05.462Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/abbfa360eb229a8623bab5f5a4f8105e445bd38ce81a89514ba55d281ad0/grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440", size = 4154368, upload-time = "2026-03-30T08:47:08.027Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d4/ae92206d01183b08613e846076115f5ac5991bae358d2a749fa864da5699/grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9", size = 4894235, upload-time = "2026-03-30T08:47:10.839Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" }, + { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" }, + { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" }, + { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" }, + { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -3290,6 +3345,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149", size = 18369, upload-time = "2026-03-04T14:17:04.796Z" }, ] +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/7f/b9e60435cfcc7590fa87436edad6822240dddbc184643a2a005301cc31f4/opentelemetry_exporter_otlp_proto_grpc-1.40.0.tar.gz", hash = "sha256:bd4015183e40b635b3dab8da528b27161ba83bf4ef545776b196f0fb4ec47740", size = 25759, upload-time = "2026-03-04T14:17:24.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/6f/7ee0980afcbdcd2d40362da16f7f9796bd083bf7f0b8e038abfbc0300f5d/opentelemetry_exporter_otlp_proto_grpc-1.40.0-py3-none-any.whl", hash = "sha256:2aa0ca53483fe0cf6405087a7491472b70335bc5c7944378a0a8e72e86995c52", size = 20304, upload-time = "2026-03-04T14:17:05.942Z" }, +] + [[package]] name = "opentelemetry-exporter-otlp-proto-http" version = "1.40.0" @@ -3308,6 +3381,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069", size = 19960, upload-time = "2026-03-04T14:17:07.153Z" }, ] +[[package]] +name = "opentelemetry-instrumentation" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/37/6bf8e66bfcee5d3c6515b79cb2ee9ad05fe573c20f7ceb288d0e7eeec28c/opentelemetry_instrumentation-0.61b0.tar.gz", hash = "sha256:cb21b48db738c9de196eba6b805b4ff9de3b7f187e4bbf9a466fa170514f1fc7", size = 32606, upload-time = "2026-03-04T14:20:16.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/3e/f6f10f178b6316de67f0dfdbbb699a24fbe8917cf1743c1595fb9dcdd461/opentelemetry_instrumentation-0.61b0-py3-none-any.whl", hash = "sha256:92a93a280e69788e8f88391247cc530fd81f16f2b011979d4d6398f805cfbc63", size = 33448, upload-time = "2026-03-04T14:19:02.447Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-asyncio" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/06/f14eacf4fde6892402a4fe1023cbca4a5d4f08f37d930ea3e414a98c85d0/opentelemetry_instrumentation_asyncio-0.61b0.tar.gz", hash = "sha256:3b173b009f108fcbc6ee4f7482e7ae8b76518a87a620ad5e7dd24e4c26066c3c", size = 14115, upload-time = "2026-03-04T14:20:22.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/8f/79913d7ebc2bd2be9a81f8ecbe0f7413c3bec55c83c89337b93c8de5417a/opentelemetry_instrumentation_asyncio-0.61b0-py3-none-any.whl", hash = "sha256:43273d5b74880b06c5a766f779fa480a50fc5a09a7c81468a60457b794e3f3cd", size = 14770, upload-time = "2026-03-04T14:19:13.057Z" }, +] + [[package]] name = "opentelemetry-proto" version = "1.40.0" @@ -3365,6 +3468,10 @@ dependencies = [ { name = "olefile" }, { name = "openai" }, { name = "openpyxl" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-instrumentation-asyncio" }, + { name = "opentelemetry-sdk" }, { name = "pdfminer-six" }, { name = "pdfplumber" }, { name = "protobuf" }, @@ -3397,6 +3504,15 @@ dependencies = [ ] [package.optional-dependencies] +benchmark = [ + { name = "datasets" }, + { name = "langchain" }, + { name = "langchain-core" }, + { name = "langchain-openai" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "tiktoken" }, +] bot = [ { name = "beautifulsoup4" }, { name = "croniter" }, @@ -3489,6 +3605,7 @@ build = [ dev = [ { name = "mypy" }, { name = "ruff" }, + { name = "setuptools-scm" }, ] doc = [ { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -3544,6 +3661,7 @@ requires-dist = [ { name = "cmake", marker = "extra == 'build'", specifier = ">=3.15" }, { name = "croniter", marker = "extra == 'bot'", specifier = ">=2.0.0" }, { name = "cryptography", specifier = ">=42.0.0" }, + { name = "datasets", marker = "extra == 'benchmark'", specifier = ">=2.0.0" }, { name = "datasets", marker = "extra == 'eval'", specifier = ">=2.0.0" }, { name = "datasets", marker = "extra == 'test'", specifier = ">=2.0.0" }, { name = "ddgs", marker = "extra == 'bot'", specifier = ">=9.0.0" }, @@ -3561,9 +3679,12 @@ requires-dist = [ { name = "hvac", marker = "extra == 'test'", specifier = ">=2.0.0" }, { name = "jinja2", specifier = ">=3.1.6" }, { name = "json-repair", specifier = ">=0.25.0" }, + { name = "langchain", marker = "extra == 'benchmark'", specifier = ">=1.0.0" }, + { name = "langchain-core", marker = "extra == 'benchmark'", specifier = ">=1.0.0" }, + { name = "langchain-openai", marker = "extra == 'benchmark'", specifier = ">=1.0.0" }, { name = "langfuse", marker = "extra == 'bot-langfuse'", specifier = ">=3.0.0" }, { name = "lark-oapi", marker = "extra == 'bot-feishu'", specifier = ">=1.0.0" }, - { name = "litellm", specifier = ">=1.0.0,<1.82.6" }, + { name = "litellm", specifier = ">=1.0.0,<1.83.1" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "markdownify", specifier = ">=0.11.0" }, { name = "msgpack", marker = "extra == 'bot'", specifier = ">=1.0.8" }, @@ -3575,7 +3696,12 @@ requires-dist = [ { name = "openpyxl", specifier = ">=3.0.0" }, { name = "opensandbox", marker = "extra == 'bot-sandbox'", specifier = ">=0.1.0" }, { name = "opensandbox-server", marker = "extra == 'bot-sandbox'", specifier = ">=0.1.0" }, + { name = "opentelemetry-api", specifier = ">=1.14" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.14" }, + { name = "opentelemetry-instrumentation-asyncio", specifier = ">=0.61b0" }, + { name = "opentelemetry-sdk", specifier = ">=1.14" }, { name = "openviking", extras = ["bot", "bot-dingtalk", "bot-feishu", "bot-fuse", "bot-langfuse", "bot-opencode", "bot-qq", "bot-sandbox", "bot-slack", "bot-telegram"], marker = "extra == 'bot-full'" }, + { name = "pandas", marker = "extra == 'benchmark'", specifier = ">=2.0.0" }, { name = "pandas", marker = "extra == 'eval'", specifier = ">=2.0.0" }, { name = "pandas", marker = "extra == 'test'", specifier = ">=2.0.0" }, { name = "pdfminer-six", specifier = ">=20251230" }, @@ -3607,12 +3733,14 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, { name = "setuptools", marker = "extra == 'build'", specifier = ">=61.0" }, { name = "setuptools-scm", marker = "extra == 'build'", specifier = ">=8.0" }, + { name = "setuptools-scm", marker = "extra == 'dev'", specifier = ">=10.0.0" }, { name = "slack-sdk", marker = "extra == 'bot-slack'", specifier = ">=3.26.0" }, { name = "socksio", marker = "extra == 'bot'", specifier = ">=1.0.0" }, { name = "sphinx", marker = "extra == 'doc'", specifier = ">=7.0.0" }, { name = "sphinx-rtd-theme", marker = "extra == 'doc'", specifier = ">=1.3.0" }, { name = "tabulate", specifier = ">=0.9.0" }, { name = "tavily-python", marker = "extra == 'bot'", specifier = ">=0.5.0" }, + { name = "tiktoken", marker = "extra == 'benchmark'", specifier = ">=0.5.0" }, { name = "tree-sitter", specifier = ">=0.23.0" }, { name = "tree-sitter-c-sharp", specifier = ">=0.23.0" }, { name = "tree-sitter-cpp", specifier = ">=0.23.0" }, @@ -3635,7 +3763,7 @@ requires-dist = [ { name = "xlrd", specifier = ">=2.0.1" }, { name = "xxhash", specifier = ">=3.0.0" }, ] -provides-extras = ["test", "dev", "doc", "eval", "gemini", "gemini-async", "ocr", "build", "bot", "bot-langfuse", "bot-telegram", "bot-feishu", "bot-dingtalk", "bot-slack", "bot-qq", "bot-sandbox", "bot-fuse", "bot-opencode", "bot-full"] +provides-extras = ["test", "dev", "doc", "eval", "gemini", "gemini-async", "ocr", "build", "bot", "bot-langfuse", "bot-telegram", "bot-feishu", "bot-dingtalk", "bot-slack", "bot-qq", "bot-sandbox", "bot-fuse", "bot-opencode", "bot-full", "benchmark"] [package.metadata.requires-dev] dev = [{ name = "pytest", specifier = ">=9.0.2" }] @@ -5332,16 +5460,18 @@ wheels = [ [[package]] name = "setuptools-scm" -version = "9.2.2" +version = "10.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "setuptools" }, { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "vcs-versioning" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7b/b1/19587742aad604f1988a8a362e660e8c3ac03adccdb71c96d86526e5eb62/setuptools_scm-9.2.2.tar.gz", hash = "sha256:1c674ab4665686a0887d7e24c03ab25f24201c213e82ea689d2f3e169ef7ef57", size = 203385, upload-time = "2025-10-19T22:08:05.608Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/b1/2a6a8ecd6f9e263754036a0b573360bdbd6873b595725e49e11139722041/setuptools_scm-10.0.5.tar.gz", hash = "sha256:bbba8fe754516cdefd017f4456721775e6ef9662bd7887fb52ae26813d4838c3", size = 56748, upload-time = "2026-03-27T15:57:05.751Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/ea/ac2bf868899d0d2e82ef72d350d97a846110c709bacf2d968431576ca915/setuptools_scm-9.2.2-py3-none-any.whl", hash = "sha256:30e8f84d2ab1ba7cb0e653429b179395d0c33775d54807fc5f1dd6671801aef7", size = 62975, upload-time = "2025-10-19T22:08:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e1/342c4434df56aa537f6ce7647eefee521d96fbb828b08acd709865767652/setuptools_scm-10.0.5-py3-none-any.whl", hash = "sha256:f611037d8aae618221503b8fa89319f073438252ae3420e01c9ceec249131a0a", size = 21695, upload-time = "2026-03-27T15:57:03.969Z" }, ] [[package]] @@ -6180,6 +6310,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, ] +[[package]] +name = "vcs-versioning" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/42/d97a7795055677961c63a1eef8e7b19d5968ed992ed3a70ab8eb012efad8/vcs_versioning-1.1.1.tar.gz", hash = "sha256:fabd75a3cab7dd8ac02fe24a3a9ba936bf258667b5a62ed468c9a1da0f5775bc", size = 97575, upload-time = "2026-03-27T20:42:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/60/73603fbcdbe5e803855bcce4414f94eaeed449083bd8183e67161af78188/vcs_versioning-1.1.1-py3-none-any.whl", hash = "sha256:b541e2ba79fc6aaa3850f8a7f88af43d97c1c80649c01142ee4146eddbc599e4", size = 79851, upload-time = "2026-03-27T20:42:40.45Z" }, +] + [[package]] name = "volcengine" version = "1.0.216"