Files
attachment_classifier/order_logger.py
Misaka_Company 622f348cf1 feat: 附件分类结果写入层 + 运行统计 + --id 指定总排号
- db.py: 新增 upsert_attachments/fetch_all_ids,实现 Common.Attachment 幂等写入
  (先删受影响 NS 旧行再批量插入,依赖唯一索引,可安全重跑)
- write_attachments.py: 写入入口,支持 --limit/--ids-file/--id(单个或逗号分隔多个)
  /--mode/--dry-run/--enable-other,运行结束打印 token 与缓存命中率汇总
- llm_client.py: LLMCallResult 捕获 usage/elapsed_ms/model/attempt
- classifier.py: classify_batch 结果透传 meta(耗时分两种、上下文 token、缓存命中率),
  新增 summarize_results 聚合批统计
- main.py: 新增 --summary 把批汇总打到 stderr,stdout 保持干净 JSON Lines
- order_logger.py: 每次 LLM 尝试补充 [调用统计] 段,便于排查耗时与缓存效果
- README.md: 对齐上述接口与统计说明

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-07-24 15:38:06 +08:00

164 lines
7.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""对话日志模块。
设计目标:每个总排号一个日志文件,纯文本格式,完整记录"这次调用到底发生了
什么"——system prompt、few-shot、实际发给模型的用户消息、模型每一次原始
回复(含被格式校验拒绝、触发重试的那些),以及最终的解析结果。成功失败都写,
不因为调用失败就少记东西;恰恰是失败的调用最需要完整日志去排查。
文件命名: {log_dir}/{总排号}_{时间戳}.log
同一个总排号如果被反复处理(比如手动重跑),不会覆盖旧日志,每次生成新文件,
方便对比"改了提示词前后,同一条记录的判断有没有变化"
"""
from __future__ import annotations
import re
from datetime import datetime
from pathlib import Path
from llm_client import LLMCallResult
from parser import ParsedResult
# 总排号可能含有对文件名不友好的字符(如斜杠),做一次保守清理
_UNSAFE_FILENAME_CHARS = re.compile(r'[\\/:*?"<>|]')
def _safe_filename_part(zong_pai_hao: str) -> str:
return _UNSAFE_FILENAME_CHARS.sub("_", zong_pai_hao)
class OrderLogger:
"""负责单个总排号在一次分类过程中的完整日志记录。
典型用法(在 classifier.py 里):
logger = OrderLogger(log_dir, zong_pai_hao, mode)
logger.log_param_text(param_text)
# ... 每次调用LLM后:
logger.log_llm_attempt(attempt_no, call_result, parsed_or_none, format_error_or_none)
# ... 最终:
logger.log_final_result(status, parsed_result_or_none)
logger.close()
"""
def __init__(self, log_dir: str | Path, zong_pai_hao: str, mode: str):
self._log_dir = Path(log_dir)
self._log_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
filename = f"{_safe_filename_part(zong_pai_hao)}_{timestamp}.log"
self._path = self._log_dir / filename
self._lines: list[str] = []
self._write_header(zong_pai_hao, mode)
@property
def path(self) -> Path:
return self._path
def _write_header(self, zong_pai_hao: str, mode: str) -> None:
self._lines.append("=" * 70)
self._lines.append(f"总排号: {zong_pai_hao}")
self._lines.append(f"分类模式: {mode}")
self._lines.append(f"开始时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
self._lines.append("=" * 70)
self._flush()
def log_param_text(self, param_text: str | None) -> None:
self._lines.append("")
self._lines.append("--- 新参数原文 ---")
self._lines.append(param_text if param_text else "(空)")
self._flush()
def log_llm_attempt(
self,
attempt_no: int,
call_result: LLMCallResult,
parsed: ParsedResult | None,
format_error: str | None,
) -> None:
"""记录一次 LLM 调用的完整往返,无论成功还是失败都调用本方法。
call_result.error 非 None 表示网络/接口层面调用失败(对话都没发起成功
或没拿到回复call_result.error 为 None 但 format_error 非 None 表示
调用成功但模型输出格式不合规;两者都为 None 表示本次调用完全成功。
"""
self._lines.append("")
self._lines.append(f"--- 第 {attempt_no} 次尝试 ---")
self._lines.append("")
self._lines.append("[发送给模型的完整对话]")
for msg in call_result.messages:
role = msg["role"]
content = msg["content"]
self._lines.append(f" [{role}]")
for line in content.splitlines():
self._lines.append(f" {line}")
self._lines.append("")
if call_result.error is not None:
self._lines.append("[调用结果] 网络/接口调用失败")
self._lines.append(f" 错误信息: {call_result.error}")
else:
self._lines.append("[调用结果] 已收到模型回复")
self._lines.append("[模型原始回复]")
for line in (call_result.raw_response or "").splitlines():
self._lines.append(f" {line}")
# 调用统计模型名、单次调用耗时、token 用量与缓存命中情况。
# 失败调用(未拿到 usage时相应字段显示"无",保证排查信息完整。
self._lines.append("")
self._lines.append("[调用统计]")
self._lines.append(f" 模型: {call_result.model}")
if call_result.elapsed_ms is not None:
self._lines.append(f" 单次调用耗时: {call_result.elapsed_ms:.1f} ms")
else:
self._lines.append(" 单次调用耗时: 无(调用失败)")
u = call_result.usage
if isinstance(u, dict):
self._lines.append(
f" tokens: prompt={u.get('prompt_tokens')} "
f"completion={u.get('completion_tokens')} "
f"total={u.get('total_tokens')}"
)
prompt = u.get("prompt_tokens") or 0
hit = u.get("prompt_cache_hit_tokens") or 0
rate = round(hit / prompt, 4) if prompt else 0.0
self._lines.append(
f" 缓存: hit={u.get('prompt_cache_hit_tokens')} "
f"miss={u.get('prompt_cache_miss_tokens')} "
f"命中率={rate}"
)
else:
self._lines.append(" 用量统计: 无(调用失败或未返回 usage")
self._lines.append("")
if format_error is not None:
self._lines.append("[格式校验] 失败")
self._lines.append(f" 原因: {format_error}")
else:
self._lines.append("[格式校验] 通过")
if parsed is not None:
self._lines.append(f" has_attachment = {parsed.has_attachment}")
self._lines.append(f" types = {parsed.types}")
self._flush()
def log_final_result(self, status: str, parsed: ParsedResult | None) -> None:
self._lines.append("")
self._lines.append("=" * 70)
self._lines.append(f"最终状态: {status}")
if parsed is not None:
self._lines.append(f"最终结果: has_attachment={parsed.has_attachment}, types={parsed.types}")
self._lines.append(f"结束时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
self._lines.append("=" * 70)
self._flush()
def _flush(self) -> None:
"""每次追加内容后立即落盘,避免进程中途异常退出导致日志丢失。"""
with self._path.open("a", encoding="utf-8") as f:
f.write("\n".join(self._lines) + "\n")
self._lines = []
def close(self) -> None:
"""预留的显式收尾方法;当前实现下 _flush 已即时写盘close 是空操作,
但保留该接口方便未来切换为缓冲写入而不改动调用方代码。"""
pass