"""收集 feedback-issue 提交流程需要的本地诊断日志。""" from __future__ import annotations import argparse import re import shutil import subprocess import sys from datetime import datetime, timedelta from pathlib import Path from typing import Optional from feedback_issue_common import ( MAX_LOGS_CHARS, format_log_selection, feedback_runtime_dir, result_payload, runtime_file, sanitize_logs, settings, write_json_file, ) _MAX_READ_BYTES = 512 * 1024 _DEFAULT_TIME_WINDOW_MINUTES = 30 _MIN_TIME_WINDOW_MINUTES = 5 _MAX_TIME_WINDOW_MINUTES = 24 * 60 _LOG_TIMESTAMP_RE = re.compile(r"(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})") _LOG_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S" _LOG_MODULE_RE = re.compile( r"^【[^】]+】\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2},\d+\s+-\s+([^\s][^\-]*?)\s+-\s+" ) _LOG_DYNAMIC_VALUE_RE = re.compile( r"(? str: """读取日志文件尾部,避免大日志一次性进入内存。""" try: size = path.stat().st_size with path.open("rb") as file_obj: if size > _MAX_READ_BYTES: file_obj.seek(size - _MAX_READ_BYTES) return file_obj.read().decode("utf-8", errors="replace") except OSError: return "" def candidate_log_files() -> list[Path]: """返回反馈诊断可读取的主日志和插件日志文件。""" files = [settings.LOG_PATH / "moviepilot.log"] plugin_log_dir = settings.LOG_PATH / "plugins" if plugin_log_dir.exists(): files.extend(sorted(plugin_log_dir.rglob("*.log"))) return [path for path in files if path.exists() and path.is_file()] def collect_doctor_report() -> dict: """调用离线 doctor 命令收集结构化诊断报告。""" commands = [] moviepilot_bin = shutil.which("moviepilot") if moviepilot_bin: commands.append([moviepilot_bin, "doctor", "--json"]) commands.append([sys.executable, "-m", "app.cli", "doctor", "--json"]) for command in commands: try: result = subprocess.run( command, cwd=str(settings.ROOT_PATH), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", timeout=30, check=False, ) except (OSError, subprocess.TimeoutExpired) as err: last_error = str(err) continue output = (result.stdout or "").strip() if not output: last_error = f"{' '.join(command)} 没有输出" continue try: payload = json_loads_from_output(output) except ValueError as err: last_error = str(err) continue payload["_command"] = " ".join(command) payload["_returncode"] = result.returncode return { "success": True, "report": payload, } return { "success": False, "error": last_error if "last_error" in locals() else "doctor 命令不可用", } def json_loads_from_output(output: str) -> dict: """从命令输出中解析 doctor JSON 对象。""" import json start = output.find("{") end = output.rfind("}") if start == -1 or end == -1 or end < start: raise ValueError("doctor 输出中未找到 JSON 对象") payload = json.loads(output[start:end + 1]) if not isinstance(payload, dict): raise ValueError("doctor JSON 顶层不是对象") return payload def normalize_keywords(keywords: Optional[list[str]]) -> list[str]: """过滤掉过短或过于宽泛的日志关键词。""" normalized: list[str] = [] for item in keywords or []: item = str(item or "").strip() if len(item) < 2: continue if item.lower() in _VAGUE_KEYWORDS: continue if item not in normalized: normalized.append(item) return normalized def has_explicit_feedback_intent(original_user_request: str) -> bool: """判断用户原话里是否出现明确要求提 Issue 的意图。""" if not original_user_request: return False normalized = original_user_request.lower().strip() if any(phrase in normalized for phrase in _FEEDBACK_STANDALONE_PHRASES): return True if any(pattern.search(normalized) for pattern in _FEEDBACK_REGEX_PATTERNS): return True has_verb = any(phrase in normalized for phrase in _FEEDBACK_VERB_PHRASES) has_target = any(token in normalized for token in _FEEDBACK_TARGET_TOKENS) return has_verb and has_target def normalize_window(time_window_minutes: int) -> int: """把传入的时间窗限制到 5 到 1440 分钟之间。""" try: window = int(time_window_minutes or _DEFAULT_TIME_WINDOW_MINUTES) except (TypeError, ValueError): window = _DEFAULT_TIME_WINDOW_MINUTES return max(_MIN_TIME_WINDOW_MINUTES, min(_MAX_TIME_WINDOW_MINUTES, window)) def parse_line_timestamp(line: str) -> Optional[datetime]: """从一行日志开头提取时间戳;提取不到返回 None。""" match = _LOG_TIMESTAMP_RE.search(line[:64]) if not match: return None try: return datetime.strptime(match.group(1), _LOG_TIMESTAMP_FORMAT) except ValueError: return None def is_meta_noise(line: str) -> bool: """判断日志行是否来自 Agent 自身的工具调度或消息框架噪音。""" match = _LOG_MODULE_RE.match(line) if not match: return False return match.group(1).strip() in _META_NOISE_MODULES def _repetition_fingerprint(line: str) -> str: """生成用于识别连续重复日志模板的指纹。""" if parse_line_timestamp(line) is None: return line normalized = _LOG_TIMESTAMP_RE.sub("