修改日志结构

This commit is contained in:
xinzhu.yin
2026-04-29 16:43:31 +08:00
parent 377bba2a0b
commit afd83448ed
6 changed files with 216 additions and 46 deletions

122
app/logging_setup.py Normal file
View File

@@ -0,0 +1,122 @@
from __future__ import annotations
import logging
import os
from datetime import datetime
from typing import Optional
# stdlib 级别 → PQLogGUI 标签
_LEVEL_TO_GUI = {
logging.DEBUG: "debug",
logging.INFO: "info",
logging.WARNING: "warning",
logging.ERROR: "error",
logging.CRITICAL: "error",
}
# PQLogGUI 标签 → stdlib 级别
GUI_LEVEL_TO_LOG = {
"debug": logging.DEBUG,
"info": logging.INFO,
"success": logging.INFO,
"warning": logging.WARNING,
"error": logging.ERROR,
"separator": logging.INFO,
"blank": logging.DEBUG,
}
# 用于在 LogRecord 上做 GUI 来源标记,避免回环
_FROM_GUI_FLAG = "_from_gui"
# 文件日志:头一行元信息,第二行正文,记录之间空行隔开,方便阅读
_FILE_LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s\n%(message)s\n"
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
_initialized = False
def _resolve_log_dir(log_dir: Optional[str]) -> str:
if log_dir:
return log_dir
# 默认放在工作目录下的 log/,方便用户直接打开查看
return os.path.join(os.getcwd(), "log")
def setup_logging(
level: int = logging.INFO,
log_dir: Optional[str] = None,
) -> str:
"""配置全局 logging返回日志目录。可重复调用第二次起为空操作。
- 不再向终端输出,避免污染控制台。
- 日志文件按日期命名:``log/YYYY-MM-DD.log``。
"""
global _initialized
root = logging.getLogger()
if _initialized:
return _resolve_log_dir(log_dir)
resolved = _resolve_log_dir(log_dir)
os.makedirs(resolved, exist_ok=True)
file_formatter = logging.Formatter(_FILE_LOG_FORMAT, datefmt=_DATE_FORMAT)
root.setLevel(level)
# 移除既有 handler避免重复 / basicConfig 默认控制台输出
for handler in list(root.handlers):
root.removeHandler(handler)
today = datetime.now().strftime("%Y-%m-%d")
log_file = os.path.join(resolved, f"{today}.log")
file_handler = logging.FileHandler(log_file, encoding="utf-8", delay=True)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(file_formatter)
root.addHandler(file_handler)
_initialized = True
logging.getLogger(__name__).info("日志系统初始化完成 dir=%s", resolved)
return resolved
class TkLogHandler(logging.Handler):
"""把 stdlib logging 的记录转发到 ``PQLogGUI`` 面板。
带 ``_from_gui`` 标记的记录会被忽略,避免 GUI → file → GUI 回环。
"""
def __init__(self, log_gui):
super().__init__()
self._log_gui = log_gui
def emit(self, record: logging.LogRecord) -> None: # noqa: D401
if getattr(record, _FROM_GUI_FLAG, False):
return
try:
message = self.format(record)
except Exception:
try:
message = record.getMessage()
except Exception:
return
gui_level = _LEVEL_TO_GUI.get(record.levelno, "info")
try:
# PQLogGUI.log 内部已处理跨线程
self._log_gui.log(message, level=gui_level, _from_logging=True)
except Exception:
self.handleError(record)
def attach_gui_handler(log_gui) -> TkLogHandler:
"""把 ``PQLogGUI`` 注册为 root logger 的 handler。已存在则替换。"""
root = logging.getLogger()
# 移除旧的 TkLogHandler保证只挂一个
for h in list(root.handlers):
if isinstance(h, TkLogHandler):
root.removeHandler(h)
handler = TkLogHandler(log_gui)
handler.setLevel(logging.INFO)
handler.setFormatter(logging.Formatter("%(name)s: %(message)s"))
root.addHandler(handler)
return handler

View File

@@ -23,6 +23,7 @@ import uuid
from io import BytesIO
from dataclasses import dataclass, asdict
from typing import Callable, List, Optional
from urllib.error import HTTPError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
@@ -41,7 +42,7 @@ _SUPPORTED_IMG_EXT = (".png", ".jpg", ".jpeg", ".bmp", ".webp")
# 测试环境后端
API_BASE_URL = "http://10.201.44.70:9018/ai-agent/"
API_PATH = "api/v1/pqtest/generate"
API_TIMEOUT = 90.0 # 后端最长 60s留余量
API_TIMEOUT = 300.0 # 后端最长 60s留余量
# 进程级会话 id多轮对话需保持一致可通过 ``reset_session`` 重置
_session_id: str = str(uuid.uuid4())
@@ -62,7 +63,6 @@ def set_session_id(session_id: str) -> str:
with _session_lock:
old = _session_id
_session_id = sid
logger.info("[AIImage] 会话切换 %s -> %s", _mask_sid(old), _mask_sid(_session_id))
return _session_id
@@ -72,7 +72,6 @@ def reset_session() -> str:
with _session_lock:
old = _session_id
_session_id = str(uuid.uuid4())
logger.info("[AIImage] 会话切换 %s -> %s", _mask_sid(old), _mask_sid(_session_id))
return _session_id
@@ -144,27 +143,66 @@ def _call_pqtest_generate(user_message: str, session_id: str, timeout: float = A
"session_id": session_id},
ensure_ascii=False,
).encode("utf-8")
request_headers = {
"Content-Type": "application/json; charset=utf-8",
"Accept": "application/json",
"User-Agent": "pqAutomationApp/1.0",
}
endpoint = _api_endpoint()
logger.info(
"[AIImage] 请求生成 sid=%s prompt_len=%d prompt=%r",
_mask_sid(session_id), len(user_message or ""), _truncate(user_message),
)
logger.debug("[AIImage] POST %s timeout=%.1fs", endpoint, timeout)
logger.info(
"[AIImage][REQUEST]\\nendpoint=%s\\nmethod=POST\\ntimeout=%.1fs\\nheaders=%s\\nbody=%s",
endpoint,
timeout,
json.dumps(request_headers, ensure_ascii=False),
payload.decode("utf-8", errors="replace"),
)
request = Request(
endpoint,
data=payload,
method="POST",
headers={
"Content-Type": "application/json; charset=utf-8",
"Accept": "application/json",
"User-Agent": "pqAutomationApp/1.0",
},
headers=request_headers,
)
t0 = time.monotonic()
try:
with urlopen(request, timeout=timeout) as response:
raw = response.read()
http_status = response.status
response_headers = dict(response.headers.items())
raw_text = raw.decode("utf-8", errors="replace")
logger.info(
"[AIImage][RESPONSE]\\nstatus=%s\\nheaders=%s\\nbody=%s",
http_status,
json.dumps(response_headers, ensure_ascii=False),
raw_text,
)
except HTTPError as exc:
elapsed = time.monotonic() - t0
err_raw = b""
try:
err_raw = exc.read() or b""
except Exception:
err_raw = b""
err_text = err_raw.decode("utf-8", errors="replace") if err_raw else ""
err_headers = {}
try:
if exc.headers is not None:
err_headers = dict(exc.headers.items())
except Exception:
err_headers = {}
logger.error(
"[AIImage][RESPONSE_ERROR] sid=%s elapsed=%.2fs status=%s reason=%s\\nheaders=%s\\nbody=%s",
_mask_sid(session_id),
elapsed,
getattr(exc, "code", "?"),
str(exc),
json.dumps(err_headers, ensure_ascii=False),
err_text,
)
raise
except Exception as exc:
elapsed = time.monotonic() - t0
logger.error(
@@ -173,15 +211,13 @@ def _call_pqtest_generate(user_message: str, session_id: str, timeout: float = A
)
raise
elapsed = time.monotonic() - t0
logger.debug(
"[AIImage] HTTP %s 收到 %d bytes elapsed=%.2fs",
http_status, len(raw), elapsed,
)
logger.info("[AIImage] HTTP %s 收到 %d bytes elapsed=%.2fs", http_status, len(raw), elapsed)
try:
result = json.loads(raw.decode("utf-8"))
except Exception as exc:
logger.error("[AIImage] 响应解析失败 sid=%s raw=%r", _mask_sid(session_id), raw[:200])
raise RuntimeError(f"AI 接口返回非 JSON{raw[:200]!r}") from exc
raw_text = raw.decode("utf-8", errors="replace")
logger.error("[AIImage] 响应解析失败 sid=%s raw=%s", _mask_sid(session_id), raw_text)
raise RuntimeError(f"AI 接口返回非 JSON{raw_text}") from exc
code = result.get("code")
message = result.get("message") or ""

View File

@@ -187,11 +187,12 @@ def toggle_ai_image_panel(self):
# ---------------- 列表 / 选中 ----------------
def reload_ai_image_list(self):
def reload_ai_image_list(self, auto_select_first=True):
"""重新扫描缓存并刷新列表。
按 ``session_id`` 分组:每个会话用一行不可选的分隔头(``── 会话 #N · 时间 ──``
其下列出该轮生成的所有图片。会话按"最近使用"倒序,组内按时间倒序。
auto_select_first: 是否自动选中第一张图片(默认 True
"""
self.ai_image_records = _svc.list_records()
self.ai_image_listbox.delete(0, tk.END)
@@ -222,7 +223,7 @@ def reload_ai_image_list(self):
flat.append(rec)
# 替换为按显示顺序展平后的列表,便于其它逻辑(前一张/后一张等)
self.ai_image_records = flat
if self.ai_image_records:
if self.ai_image_records and auto_select_first:
# 选中第一张实际记录
for row, ridx in enumerate(self._ai_image_row_map):
if ridx is not None:
@@ -333,7 +334,7 @@ def _start_new_session(self):
return
_svc.reset_session()
self.ai_image_status_var.set("已开启新对话")
reload_ai_image_list(self)
reload_ai_image_list(self, auto_select_first=False)
def _session_id_for_row(self, row: int) -> str:
@@ -372,8 +373,7 @@ def _update_request_progress(self):
if not getattr(self, "_ai_image_requesting", False):
self._ai_image_progress_job = None
return
phases = ["后端处理中…", "正在生成图片…", "正在下载结果…", "即将完成…"]
self.ai_image_status_var.set(phases[self._ai_image_progress_phase % len(phases)])
self.ai_image_status_var.set("正在生成图片…")
self._ai_image_progress_phase += 1
self._ai_image_progress_job = self.root.after(900, lambda: _update_request_progress(self))

View File

@@ -1,8 +1,22 @@
import logging
import threading
from datetime import datetime
import tkinter as tk
import ttkbootstrap as ttk
# 与 app.logging_setup 共享的映射;放在这里避免循环 import
_GUI_LEVEL_TO_LOG = {
"debug": logging.DEBUG,
"info": logging.INFO,
"success": logging.INFO,
"warning": logging.WARNING,
"error": logging.ERROR,
"separator": logging.INFO,
"blank": logging.DEBUG,
}
class PQLogGUI(ttk.Frame):
VALID_LEVELS = {"info", "success", "warning", "error", "debug", "separator", "blank"}
@@ -10,6 +24,8 @@ class PQLogGUI(ttk.Frame):
super().__init__(parent)
self._line_count = 0
self._max_lines = 1500
# 与 stdlib logging 联通的 logger由 logging_setup 配置 file handler
self._logger = logging.getLogger("pq.gui")
self.create_widgets()
def create_widgets(self):
@@ -62,13 +78,25 @@ class PQLogGUI(ttk.Frame):
self._configure_tags()
self.log_text.config(state=tk.DISABLED)
def log(self, message, level="info"):
def log(self, message, level="info", _from_logging=False):
if threading.current_thread() is not threading.main_thread():
self.after(0, self.log, message, level)
self.after(0, self.log, message, level, _from_logging)
return
text = "" if message is None else str(message)
normalized_level = self._normalize_level(level, text)
# 转发到 stdlib logging落到文件_from_logging=True 表示来源已是
# logging不再回写避免重复。带 _from_gui 标记TkLogHandler 会跳过。
if not _from_logging and normalized_level != "blank":
log_level = _GUI_LEVEL_TO_LOG.get(normalized_level, logging.INFO)
try:
self._logger.log(
log_level, text, extra={"_from_gui": True}
)
except Exception:
pass
self.log_text.config(state=tk.NORMAL)
self._append_message(text, normalized_level)
self.log_text.see(tk.END)

View File

@@ -1,7 +1,6 @@
import ttkbootstrap as ttk
import tkinter as tk
from tkinter import messagebox, filedialog
import sys
import threading
import time
import os
@@ -23,6 +22,7 @@ from app.views.panels import cct_panel as _ccp
from app.views.panels import main_layout as _main
from app.views.panels import ai_image_panel as _aip
from app.views import panel_manager as PM
from app.logging_setup import setup_logging, attach_gui_handler
# Step 0/1 重构:资源工具和纯算法已迁移到 app/ 包,这里重新导入以保持
# 对原函数名/方法名的向后兼容(老代码内部仍用 self.calculate_* 调用)。
@@ -877,17 +877,13 @@ class PQAutomationApp:
def main():
try:
# 全局日志:默认 INFO 输出到 stderr便于排查 AI 接口等关键事件
import logging as _logging
if not _logging.getLogger().handlers:
_logging.basicConfig(
level=_logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
setup_logging()
# root = tk.Tk()
root = ttk.Window(themename="yeti")
app = PQAutomationApp(root)
# GUI 创建完成后,把 logging 记录同步到日志面板
if hasattr(app, "log_gui"):
attach_gui_handler(app.log_gui)
root.mainloop()
except Exception as e:
print("程序发生错误:", e)

View File

@@ -32,13 +32,7 @@
"timing": "DMT 1920x 1080 @ 60Hz",
"color_format": "RGB",
"bpc": 8,
"colorimetry": "sRGB",
"cct_params": {
"x_ideal": 0.3127,
"x_tolerance": 0.003,
"y_ideal": 0.329,
"y_tolerance": 0.003
}
"colorimetry": "sRGB"
},
"hdr_movie": {
"name": "HDR Movie测试",
@@ -52,17 +46,11 @@
"timing": "DMT 1920x 1080 @ 60Hz",
"color_format": "RGB",
"bpc": 8,
"colorimetry": "sRGB",
"cct_params": {
"x_ideal": 0.3127,
"x_tolerance": 0.003,
"y_ideal": 0.329,
"y_tolerance": 0.003
}
"colorimetry": "sRGB"
}
},
"device_config": {
"ca_com": "COM3",
"ca_com": "COM1",
"ucd_list": "0: UCD-323 [2128C209]",
"ca_channel": "0"
},