140 lines
5.0 KiB
Python
140 lines
5.0 KiB
Python
import json
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional
|
||
from uuid import uuid4
|
||
|
||
from src.config.config import global_config
|
||
|
||
|
||
class PlanReplyLogger:
|
||
"""独立的Plan/Reply日志记录器,负责落盘和容量控制。"""
|
||
|
||
_BASE_DIR = Path("logs")
|
||
_PLAN_DIR = _BASE_DIR / "plan"
|
||
_REPLY_DIR = _BASE_DIR / "reply"
|
||
_TRIM_COUNT = 100
|
||
|
||
@classmethod
|
||
def _get_max_per_chat(cls) -> int:
|
||
"""从配置中获取每个聊天流最大保存的日志数量"""
|
||
return getattr(global_config.chat, "plan_reply_log_max_per_chat", 1000)
|
||
|
||
@classmethod
|
||
def log_plan(
|
||
cls,
|
||
chat_id: str,
|
||
prompt: str,
|
||
reasoning: str,
|
||
raw_output: Optional[str],
|
||
raw_reasoning: Optional[str],
|
||
actions: List[Any],
|
||
timing: Optional[Dict[str, Any]] = None,
|
||
extra: Optional[Dict[str, Any]] = None,
|
||
) -> None:
|
||
payload = {
|
||
"type": "plan",
|
||
"chat_id": chat_id,
|
||
"timestamp": time.time(),
|
||
"prompt": prompt,
|
||
"reasoning": reasoning,
|
||
"raw_output": raw_output,
|
||
"raw_reasoning": raw_reasoning,
|
||
"actions": [cls._serialize_action(action) for action in actions],
|
||
"timing": timing or {},
|
||
"extra": cls._safe_data(extra),
|
||
}
|
||
cls._write_json(cls._PLAN_DIR, chat_id, payload)
|
||
|
||
@classmethod
|
||
def log_reply(
|
||
cls,
|
||
chat_id: str,
|
||
prompt: str,
|
||
output: Optional[str],
|
||
processed_output: Optional[List[Any]],
|
||
model: Optional[str],
|
||
timing: Optional[Dict[str, Any]] = None,
|
||
reasoning: Optional[str] = None,
|
||
think_level: Optional[int] = None,
|
||
error: Optional[str] = None,
|
||
success: bool = True,
|
||
) -> None:
|
||
payload = {
|
||
"type": "reply",
|
||
"chat_id": chat_id,
|
||
"timestamp": time.time(),
|
||
"prompt": prompt,
|
||
"output": output,
|
||
"processed_output": cls._safe_data(processed_output),
|
||
"model": model,
|
||
"reasoning": reasoning,
|
||
"think_level": think_level,
|
||
"timing": timing or {},
|
||
"error": error if not success else None,
|
||
"success": success,
|
||
}
|
||
cls._write_json(cls._REPLY_DIR, chat_id, payload)
|
||
|
||
@classmethod
|
||
def _write_json(cls, base_dir: Path, chat_id: str, payload: Dict[str, Any]) -> None:
|
||
chat_dir = base_dir / chat_id
|
||
chat_dir.mkdir(parents=True, exist_ok=True)
|
||
file_path = chat_dir / f"{int(time.time() * 1000)}_{uuid4().hex[:8]}.json"
|
||
try:
|
||
with file_path.open("w", encoding="utf-8") as f:
|
||
json.dump(cls._safe_data(payload), f, ensure_ascii=False, indent=2)
|
||
finally:
|
||
cls._trim_overflow(chat_dir)
|
||
|
||
@classmethod
|
||
def _trim_overflow(cls, chat_dir: Path) -> None:
|
||
"""超过阈值时删除最老的若干文件,避免目录无限增长。"""
|
||
files = sorted(chat_dir.glob("*.json"), key=lambda p: p.stat().st_mtime)
|
||
max_per_chat = cls._get_max_per_chat()
|
||
if len(files) <= max_per_chat:
|
||
return
|
||
# 删除最老的 TRIM_COUNT 条
|
||
for old_file in files[: cls._TRIM_COUNT]:
|
||
try:
|
||
old_file.unlink()
|
||
except FileNotFoundError:
|
||
continue
|
||
|
||
@classmethod
|
||
def _serialize_action(cls, action: Any) -> Dict[str, Any]:
|
||
# ActionPlannerInfo 结构的轻量序列化,避免引用复杂对象
|
||
message_info = None
|
||
action_message = getattr(action, "action_message", None)
|
||
if action_message:
|
||
user_info = getattr(action_message, "user_info", None)
|
||
message_info = {
|
||
"message_id": getattr(action_message, "message_id", None),
|
||
"user_id": getattr(user_info, "user_id", None) if user_info else None,
|
||
"platform": getattr(user_info, "platform", None) if user_info else None,
|
||
"text": getattr(action_message, "processed_plain_text", None),
|
||
}
|
||
|
||
return {
|
||
"action_type": getattr(action, "action_type", None),
|
||
"reasoning": getattr(action, "reasoning", None),
|
||
"action_data": cls._safe_data(getattr(action, "action_data", None)),
|
||
"action_message": message_info,
|
||
"available_actions": cls._safe_data(getattr(action, "available_actions", None)),
|
||
"action_reasoning": getattr(action, "action_reasoning", None),
|
||
}
|
||
|
||
@classmethod
|
||
def _safe_data(cls, value: Any) -> Any:
|
||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||
return value
|
||
if isinstance(value, dict):
|
||
return {str(k): cls._safe_data(v) for k, v in value.items()}
|
||
if isinstance(value, (list, tuple, set)):
|
||
return [cls._safe_data(v) for v in value]
|
||
if isinstance(value, Path):
|
||
return str(value)
|
||
# Fallback to string for other complex types
|
||
return str(value)
|
||
|