修改日志结构

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 io import BytesIO
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from typing import Callable, List, Optional from typing import Callable, List, Optional
from urllib.error import HTTPError
from urllib.parse import urlparse from urllib.parse import urlparse
from urllib.request import Request, urlopen 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_BASE_URL = "http://10.201.44.70:9018/ai-agent/"
API_PATH = "api/v1/pqtest/generate" API_PATH = "api/v1/pqtest/generate"
API_TIMEOUT = 90.0 # 后端最长 60s留余量 API_TIMEOUT = 300.0 # 后端最长 60s留余量
# 进程级会话 id多轮对话需保持一致可通过 ``reset_session`` 重置 # 进程级会话 id多轮对话需保持一致可通过 ``reset_session`` 重置
_session_id: str = str(uuid.uuid4()) _session_id: str = str(uuid.uuid4())
@@ -62,7 +63,6 @@ def set_session_id(session_id: str) -> str:
with _session_lock: with _session_lock:
old = _session_id old = _session_id
_session_id = sid _session_id = sid
logger.info("[AIImage] 会话切换 %s -> %s", _mask_sid(old), _mask_sid(_session_id))
return _session_id return _session_id
@@ -72,7 +72,6 @@ def reset_session() -> str:
with _session_lock: with _session_lock:
old = _session_id old = _session_id
_session_id = str(uuid.uuid4()) _session_id = str(uuid.uuid4())
logger.info("[AIImage] 会话切换 %s -> %s", _mask_sid(old), _mask_sid(_session_id))
return _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}, "session_id": session_id},
ensure_ascii=False, ensure_ascii=False,
).encode("utf-8") ).encode("utf-8")
request_headers = {
"Content-Type": "application/json; charset=utf-8",
"Accept": "application/json",
"User-Agent": "pqAutomationApp/1.0",
}
endpoint = _api_endpoint() endpoint = _api_endpoint()
logger.info( logger.info(
"[AIImage] 请求生成 sid=%s prompt_len=%d prompt=%r", "[AIImage] 请求生成 sid=%s prompt_len=%d prompt=%r",
_mask_sid(session_id), len(user_message or ""), _truncate(user_message), _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( request = Request(
endpoint, endpoint,
data=payload, data=payload,
method="POST", method="POST",
headers={ headers=request_headers,
"Content-Type": "application/json; charset=utf-8",
"Accept": "application/json",
"User-Agent": "pqAutomationApp/1.0",
},
) )
t0 = time.monotonic() t0 = time.monotonic()
try: try:
with urlopen(request, timeout=timeout) as response: with urlopen(request, timeout=timeout) as response:
raw = response.read() raw = response.read()
http_status = response.status 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: except Exception as exc:
elapsed = time.monotonic() - t0 elapsed = time.monotonic() - t0
logger.error( logger.error(
@@ -173,15 +211,13 @@ def _call_pqtest_generate(user_message: str, session_id: str, timeout: float = A
) )
raise raise
elapsed = time.monotonic() - t0 elapsed = time.monotonic() - t0
logger.debug( logger.info("[AIImage] HTTP %s 收到 %d bytes elapsed=%.2fs", http_status, len(raw), elapsed)
"[AIImage] HTTP %s 收到 %d bytes elapsed=%.2fs",
http_status, len(raw), elapsed,
)
try: try:
result = json.loads(raw.decode("utf-8")) result = json.loads(raw.decode("utf-8"))
except Exception as exc: except Exception as exc:
logger.error("[AIImage] 响应解析失败 sid=%s raw=%r", _mask_sid(session_id), raw[:200]) raw_text = raw.decode("utf-8", errors="replace")
raise RuntimeError(f"AI 接口返回非 JSON{raw[:200]!r}") from exc 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") code = result.get("code")
message = result.get("message") or "" 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 · 时间 ──`` 按 ``session_id`` 分组:每个会话用一行不可选的分隔头(``── 会话 #N · 时间 ──``
其下列出该轮生成的所有图片。会话按"最近使用"倒序,组内按时间倒序。 其下列出该轮生成的所有图片。会话按"最近使用"倒序,组内按时间倒序。
auto_select_first: 是否自动选中第一张图片(默认 True
""" """
self.ai_image_records = _svc.list_records() self.ai_image_records = _svc.list_records()
self.ai_image_listbox.delete(0, tk.END) self.ai_image_listbox.delete(0, tk.END)
@@ -222,7 +223,7 @@ def reload_ai_image_list(self):
flat.append(rec) flat.append(rec)
# 替换为按显示顺序展平后的列表,便于其它逻辑(前一张/后一张等) # 替换为按显示顺序展平后的列表,便于其它逻辑(前一张/后一张等)
self.ai_image_records = flat 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): for row, ridx in enumerate(self._ai_image_row_map):
if ridx is not None: if ridx is not None:
@@ -333,7 +334,7 @@ def _start_new_session(self):
return return
_svc.reset_session() _svc.reset_session()
self.ai_image_status_var.set("已开启新对话") 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: 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): if not getattr(self, "_ai_image_requesting", False):
self._ai_image_progress_job = None self._ai_image_progress_job = None
return return
phases = ["后端处理中…", "正在生成图片…", "正在下载结果…", "即将完成…"] self.ai_image_status_var.set("正在生成图片…")
self.ai_image_status_var.set(phases[self._ai_image_progress_phase % len(phases)])
self._ai_image_progress_phase += 1 self._ai_image_progress_phase += 1
self._ai_image_progress_job = self.root.after(900, lambda: _update_request_progress(self)) self._ai_image_progress_job = self.root.after(900, lambda: _update_request_progress(self))

View File

@@ -1,8 +1,22 @@
import logging
import threading import threading
from datetime import datetime from datetime import datetime
import tkinter as tk import tkinter as tk
import ttkbootstrap as ttk 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): class PQLogGUI(ttk.Frame):
VALID_LEVELS = {"info", "success", "warning", "error", "debug", "separator", "blank"} VALID_LEVELS = {"info", "success", "warning", "error", "debug", "separator", "blank"}
@@ -10,6 +24,8 @@ class PQLogGUI(ttk.Frame):
super().__init__(parent) super().__init__(parent)
self._line_count = 0 self._line_count = 0
self._max_lines = 1500 self._max_lines = 1500
# 与 stdlib logging 联通的 logger由 logging_setup 配置 file handler
self._logger = logging.getLogger("pq.gui")
self.create_widgets() self.create_widgets()
def create_widgets(self): def create_widgets(self):
@@ -62,13 +78,25 @@ class PQLogGUI(ttk.Frame):
self._configure_tags() self._configure_tags()
self.log_text.config(state=tk.DISABLED) 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(): 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 return
text = "" if message is None else str(message) text = "" if message is None else str(message)
normalized_level = self._normalize_level(level, text) 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.log_text.config(state=tk.NORMAL)
self._append_message(text, normalized_level) self._append_message(text, normalized_level)
self.log_text.see(tk.END) self.log_text.see(tk.END)

View File

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

View File

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