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..6cba8d87b --- /dev/null +++ b/benchmark/locomo/vikingbot/import_and_eval_one.sh @@ -0,0 +1,62 @@ +#!/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 + +set -e + +SAMPLE=$1 +QUESTION_INDEX=${2:-0} +INPUT_FILE=~/.test_data/locomo10.json + +if [ -z "$SAMPLE" ]; then + echo "Usage: $0 " + echo " sample_index: 数字索引 (0,1,2...) 或 sample_id (conv-26)" + echo " question_index: 问题索引,默认 0" + exit 1 +fi + +# 判断是数字还是 sample_id +if [[ "$SAMPLE" =~ ^-?[0-9]+$ ]]; then + SAMPLE_INDEX=$SAMPLE + 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 + echo "Using sample_id: $SAMPLE (index: $SAMPLE_INDEX)" +fi + +# 导入对话 +echo "[1/2] Importing 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 3 + +# 运行评测 +echo "[2/2] Running evaluation..." +python benchmark/locomo/vikingbot/run_eval.py \ + "$INPUT_FILE" \ + --sample "$SAMPLE_INDEX" \ + --question-index "$QUESTION_INDEX" \ + --count 1 + +echo "Done!" \ 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 d85618bd8..94a69d8ec 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) @@ -225,7 +242,26 @@ def mark_ingested( # OpenViking import # --------------------------------------------------------------------------- def _parse_token_usage(commit_result: Dict[str, Any]) -> Dict[str, int]: - """解析Token使用数据(从commit返回的telemetry中提取)""" + """解析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 { @@ -233,7 +269,7 @@ def _parse_token_usage(commit_result: Dict[str, Any]) -> Dict[str, int]: "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) + "total": tokens.get("total", 0), } @@ -241,7 +277,7 @@ 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, ) -> Dict[str, int]: """Save messages to OpenViking via OpenViking SDK client. Returns token usage dict with embedding and vlm token counts. @@ -283,25 +319,42 @@ async def viking_ingest( session_id=session_id, role=msg["role"], parts=[{"type": "text", "text": msg["text"]}], - created_at=msg_created_at + created_at=msg_created_at, ) # Commit result = await client.commit_session(session_id, telemetry=True) - if result.get("status") != "committed": + # 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}") - # 直接从commit结果中提取token使用情况 - token_usage = _parse_token_usage(result) - - return token_usage + # 等待 task 完成以获取准确 token 消耗 + task_id = result.get("task_id") + if task_id: + while True: + 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 status in ("failed", "unknown"): + raise RuntimeError(f"Task {task_id} failed: {task}") + await asyncio.sleep(1) + else: + token_usage = {"embedding": 0, "vlm": 0, "total": 0} + + # 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() -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)) @@ -311,6 +364,7 @@ def sync_viking_ingest(messages: List[Dict[str, Any]], openviking_url: str, sess # 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: @@ -328,17 +382,22 @@ async def process_single_session( run_time: str, ingest_record: Dict[str, Any], args: argparse.Namespace, - semaphore: asyncio.Semaphore + 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 + result = await viking_ingest( + messages, args.openviking_url, semaphore, meta.get("date_time") + ) + 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 = { @@ -349,7 +408,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 @@ -371,7 +432,7 @@ async def process_single_session( "sample_id": sample_id, "session": session_key, "status": "error", - "error": str(e) + "error": str(e), } # 写入错误日志 @@ -398,7 +459,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") @@ -408,19 +472,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"] @@ -428,29 +493,36 @@ 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, + semaphore=semaphore, ) - 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 @@ -462,20 +534,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) @@ -490,30 +563,26 @@ async def run_import(args: argparse.Namespace) -> None: run_time=run_time, ingest_record=ingest_record, args=args, - semaphore=semaphore + 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 @@ -526,7 +595,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) @@ -537,12 +609,13 @@ async def run_import(args: argparse.Namespace) -> None: # CLI # --------------------------------------------------------------------------- + def main(): 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)" + help="Path to input file (.txt or LoCoMo .json)", ) parser.add_argument( "--success-csv", @@ -602,4 +675,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() 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..d927bac1d 100644 --- a/benchmark/locomo/vikingbot/run_eval.py +++ b/benchmark/locomo/vikingbot/run_eval.py @@ -7,9 +7,46 @@ 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 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 中提取最晚的对话时间,返回 ISO 格式日期""" + conversation = sample.get("conversation", {}) + # 找所有 session_N_date_time 字段 + date_times = {k: v for k, v in conversation.items() if "date_time" in k} + if not date_times: + return None + + # 解析所有时间,取最晚的一个 + latest_dt = None + for key, date_str in date_times.items(): + dt = parse_locomo_datetime(date_str) + if dt: + if latest_dt is None or dt > latest_dt: + latest_dt = dt + + if latest_dt: + return latest_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 +59,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,11 +69,15 @@ 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, ) -> list[dict]: """加载LoCoMo数据集的QA部分,支持JSON和CSV格式""" 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: @@ -51,7 +93,16 @@ def load_locomo_qa( 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] qa_list.append( { "sample_id": sample_id, @@ -59,17 +110,36 @@ def load_locomo_qa( "answer": qa["answer"], "category": qa.get("category", ""), "evidence": qa.get("evidence", []), + "question_time": question_time, } ) + else: + for qa in qa_items: + qa_list.append( + { + "sample_id": sample_id, + "question": qa["question"], + "answer": qa["answer"], + "category": qa.get("category", ""), + "evidence": qa.get("evidence", []), + "question_time": question_time, + } + ) 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 +) -> tuple[str, dict, float, int, list]: """执行vikingbot chat命令,返回回答、token使用情况、耗时(秒)、迭代次数、使用的工具列表""" - input = f"Answer the question directly: {question}" + # 如果有 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"] start_time = time.time() try: @@ -114,14 +184,9 @@ 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(): @@ -143,6 +208,12 @@ def main(): default=None, help="LoCoMo sample index (0-based), default all samples", ) + parser.add_argument( + "--question-index", + type=int, + default=None, + 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" ) @@ -151,11 +222,17 @@ def main(): ) 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) + qa_list = load_locomo_qa( + args.input, args.sample, args.count, question_index=args.question_index + ) total = len(qa_list) # 加载已处理的问题 @@ -169,6 +246,7 @@ def main(): "sample_id", "question", "answer", + "question_time", "response", "token_usage", "time_cost", @@ -178,6 +256,16 @@ def main(): ] # 打开CSV文件,不存在则创建写表头,存在则追加 file_exists = os.path.exists(args.output) + # 兼容旧结果:如果文件存在但没有 question_time 列,则删除重建 + if file_exists: + with open(args.output, "r", encoding="utf-8", newline="") as f: + reader = csv.reader(f) + first_row = next(reader) + if "question_time" not in first_row: + print(f"Old result missing 'question_time' column, removing and recreating...") + os.remove(args.output) + file_exists = False + # 创建线程锁,确保多线程写文件安全 write_lock = threading.Lock() @@ -191,20 +279,28 @@ def main(): # 过滤掉已经处理过的问题 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") + 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") 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) + response, token_usage, time_cost, iteration, tools_used_names = run_vikingbot_chat( + question, question_time + ) row = { "sample_id": qa_item["sample_id"], "question": question, "answer": answer, + "question_time": question_time or "", "response": response, "token_usage": json.dumps(token_usage, ensure_ascii=False), "time_cost": round(time_cost, 2), diff --git a/benchmark/locomo/vikingbot/run_full_eval.sh b/benchmark/locomo/vikingbot/run_full_eval.sh index 72d58f739..26629cf46 100755 --- a/benchmark/locomo/vikingbot/run_full_eval.sh +++ b/benchmark/locomo/vikingbot/run_full_eval.sh @@ -2,29 +2,35 @@ 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 +# 使用 JSON 格式(包含对话时间,用于时间上下文注入) +INPUT_FILE=~/.test_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 "等待 3 分钟..." + sleep 180 +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 --threads 20 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 +python benchmark/locomo/vikingbot/judge.py --input ./result/locomo_result_multi_read_all.csv --parallel 10 echo "等待 3 分钟..." sleep 180 # 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/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/openviking_mount/ov_server.py b/bot/vikingbot/openviking_mount/ov_server.py index bf36fdeae..4ff2a3d6a 100644 --- a/bot/vikingbot/openviking_mount/ov_server.py +++ b/bot/vikingbot/openviking_mount/ov_server.py @@ -433,7 +433,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 8934bcfb3..2f1078f1e 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: @@ -126,6 +129,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( @@ -155,7 +159,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: @@ -343,6 +347,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 = "", @@ -364,6 +369,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/events.yaml b/openviking/prompts/templates/memory/events.yaml index 0c00a8f56..4b8856a94 100644 --- a/openviking/prompts/templates/memory/events.yaml +++ b/openviking/prompts/templates/memory/events.yaml @@ -25,6 +25,18 @@ fields: Event name in Chinese or English. If English, use lowercase with underscores, max 3 words. Do not include any dates. merge_op: immutable + - name: event_type + type: string + description: | + Abstract event type (more general than event_name). + Examples: speech, workshop, conference, ceremony, meeting, class, volunteer_activity + + - name: target_audience + type: string + description: | + Who is the target audience for this event. + Examples: children, students, developers, adults, professionals, parents + - name: goal type: string description: | @@ -40,5 +52,6 @@ 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. + Example: "0-3,40-45" means extract messages 0-3 and 40-45. + Limit each event to 15 messages max. Save only important parts or split into multiple events. merge_op: immutable 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/soul.yaml b/openviking/prompts/templates/memory/soul.yaml new file mode 100644 index 000000000..0958984d3 --- /dev/null +++ b/openviking/prompts/templates/memory/soul.yaml @@ -0,0 +1,52 @@ +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/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..a19491fba 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,7 +193,7 @@ 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"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..df2f7b6e6 100644 --- a/openviking/session/memory/extract_loop.py +++ b/openviking/session/memory/extract_loop.py @@ -29,6 +29,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__) @@ -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 @@ -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): @@ -284,7 +290,7 @@ 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( @@ -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,8 +356,8 @@ 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 @@ -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}") @@ -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, + "default", + "default", + 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..693d33bb2 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,99 @@ 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 + + logger = get_logger(__name__) + + 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" - # Register built-in types - # These can also be loaded from YAML files - _register_builtin_types(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() + + 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 + + # Render content with init_value + try: + template = env.from_string(schema.content_template) + content = template.render(**fields_with_init).strip() + except Exception: + continue + + # Add MEMORY_FIELDS comment with field metadata + from openviking.session.memory.utils.content import serialize_with_metadata - # Load from schemas directory if provided - if schemas_dir: - registry.load_from_directory(schemas_dir) + metadata = { + "memory_type": schema.memory_type, + "fields": fields_with_init, + } + full_content = serialize_with_metadata(content, metadata) - return registry + # 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 _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 +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..2feeef5d3 100644 --- a/openviking/session/memory/memory_updater.py +++ b/openviking/session/memory/memory_updater.py @@ -23,6 +23,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 @@ -130,7 +131,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 @@ -245,32 +254,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 +283,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,74 +292,15 @@ 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( - 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: @@ -390,7 +335,7 @@ def _render_content_template( jinja_template = env.from_string(template) return jinja_template.render(**template_vars).strip() except Exception as e: - logger.error(f"Template rendering failed: {e}") + tracer.error(f"Template rendering failed: {e}") raise def _is_patch_format(self, content: Any) -> bool: @@ -399,13 +344,27 @@ def _is_patch_format(self, content: Any) -> bool: return isinstance(content, StrPatch) - async def _apply_edit(self, flat_model: Any, uri: str, ctx: RequestContext) -> None: - """Apply edit operation from a flat model.""" + async def _apply_edit( + self, + flat_model: Any, + uri: str, + ctx: RequestContext, + extract_context: Any = None, + memory_type: str = None, + ) -> bool: + """Apply edit operation from a flat model. + + Returns: + True if file was edited (existed), False if file was written (new) + """ 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) + # 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 try: current_full_content = await viking_fs.read_file(uri, ctx=ctx) or "" @@ -414,24 +373,106 @@ async def _apply_edit(self, flat_model: Any, uri: str, ctx: RequestContext) -> N # 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 + # Write operation (new file) - with template rendering + # Extract content - priority: model_dict["content"] + content = model_dict.pop("content", None) or "" + + 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: + tracer.info( + f"[content_template] Rendering template for {memory_type_str}, " + f"business_fields={list(business_fields.keys())}, " + f"extract_context={'provided' if extract_context else 'None'}" + ) + rendered_content = self._render_content_template( + schema.content_template, + business_fields, + extract_context=extract_context, + ) + if rendered_content: + content = rendered_content + tracer.info( + f"[content_template] Rendered result (first 200 chars): {rendered_content[:200]}" + ) + else: + tracer.warning( + f"[content_template] Rendered content is empty for {memory_type_str}" + ) + except Exception as e: + tracer.error( + f"Failed to render content template for memory type {memory_type_str}: {e}" + ) + + # Collect metadata + metadata = business_fields.copy() + + # Serialize content with metadata + full_content = serialize_with_metadata(content, metadata) + + # Write content to VikingFS + await viking_fs.write_file(uri, full_content, ctx=ctx) + return False # New file written # Has StrPatch field but file doesn't exist - cannot apply - logger.warning(f"Memory not found for edit: {uri}") - return + tracer.error(f"Memory not found for edit: {uri}") + return False # Deserialize content and metadata current_plain_content, current_metadata = deserialize_full(current_full_content) - # Get memory type schema - memory_type_str = model_dict.get("memory_type") or current_metadata.get("memory_type") + # Get memory type schema - use parameter 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: + tracer.info( + f"[content_template] Editing: Rendering template for {memory_type_str}, " + f"business_fields={list(business_fields.keys())}, " + f"extract_context={'provided' if extract_context else 'None'}" + ) + rendered_content = self._render_content_template( + schema.content_template, + business_fields, + extract_context=extract_context, + ) + if rendered_content: + # 用渲染后的 content 覆盖 model_dict 中的 content + model_dict["content"] = rendered_content + tracer.info( + f"[content_template] Edited result (first 200 chars): {rendered_content[:200]}" + ) + else: + tracer.warning( + f"[content_template] Edited render result is empty for {memory_type_str}" + ) + except Exception as e: + tracer.error( + f"Failed to render content template for edit {memory_type_str}: {e}" + ) # Apply all fields (including content) through MergeOp new_plain_content = current_plain_content @@ -474,7 +515,7 @@ async def _apply_edit(self, flat_model: Any, uri: str, ctx: RequestContext) -> N self._print_diff(uri, current_plain_content, new_plain_content) await viking_fs.write_file(uri, new_full_content, ctx=ctx) - logger.debug(f"Edited memory: {uri}") + return True # File was edited (existed) async def _apply_delete(self, uri: str, ctx: RequestContext) -> None: """Apply delete operation (uri is already a string).""" @@ -484,9 +525,8 @@ 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( @@ -516,13 +556,12 @@ async def _apply_edit_overview( 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}") + pass # 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): # 空字符串保持原值 @@ -553,7 +592,6 @@ async def _apply_edit_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) @@ -600,9 +638,8 @@ async def _write_abstract_from_overview( 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}") + tracer.error(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.""" @@ -637,12 +674,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/session_extract_context_provider.py b/openviking/session/memory/session_extract_context_provider.py index 732a46ac5..5f537508a 100644 --- a/openviking/session/memory/session_extract_context_provider.py +++ b/openviking/session/memory/session_extract_context_provider.py @@ -225,6 +225,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 +241,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 @@ -354,10 +357,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/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..73166d2ca 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) @@ -447,6 +451,8 @@ def resolve_all_operations( Resolve URIs for all operations. Supports both legacy format (write_uris/edit_uris) and new per-memory_type format. + 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,31 +476,25 @@ 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 + # Legacy format - unify both write and edit into operations list write_uris = operations.write_uris if hasattr(operations, "write_uris") else [] edit_uris = operations.edit_uris if hasattr(operations, "edit_uris") else [] @@ -505,7 +505,7 @@ def resolve_all_operations( ) # 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( + resolved.operations.append( ResolvedOperation(model=op, uri=uri, memory_type=memory_type) ) except Exception as e: @@ -517,7 +517,7 @@ def resolve_all_operations( 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( + resolved.operations.append( ResolvedOperation(model=op, uri=uri, memory_type=memory_type) ) except Exception as e: @@ -567,24 +567,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 6c3ca03a4..4d89bbf82 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 @@ -460,8 +466,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 5b7e89ea4..385e67b33 100644 --- a/openviking/sync_client.py +++ b/openviking/sync_client.py @@ -71,6 +71,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. @@ -79,10 +80,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 98fa54ced..b65880e68 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_utils.py b/tests/session/memory/test_memory_utils.py index 9d26e40be..da02d1f91 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: diff --git a/uv.lock b/uv.lock index 4b6d38957..0add27d5d 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" }, @@ -3544,6 +3660,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,6 +3678,9 @@ 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" }, @@ -3575,7 +3695,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" }, @@ -3613,6 +3738,7 @@ requires-dist = [ { 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 +3761,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" }]