新增 doctor 诊断自救功能

This commit is contained in:
jxxghp
2026-06-12 15:55:24 +08:00
parent 10dcb3727e
commit 735a1ebf27
23 changed files with 1635 additions and 56 deletions

View File

@@ -4,6 +4,9 @@ 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
@@ -109,6 +112,67 @@ def candidate_log_files() -> list[Path]:
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] = []
@@ -256,6 +320,7 @@ def collect_diagnostics(
"keywords": normalized_keywords,
"found": bool(logs.strip()),
"logs": logs,
"doctor": collect_doctor_report(),
"source_files": source_files,
"created_at": datetime.now().isoformat(timespec="seconds"),
}
@@ -268,6 +333,7 @@ def collect_diagnostics(
"source_files": source_files,
"log_bytes": len(logs.encode("utf-8", errors="replace")),
"log_lines": len(logs.splitlines()) if logs else 0,
"doctor_collected": bool(diagnostics["doctor"].get("success")),
"message": (
"已收集并写入反馈诊断日志文件。"
if logs

View File

@@ -47,6 +47,7 @@ MAX_BODY_CHARS = 60 * 1024
MAX_LOGS_CHARS = 8 * 1024
MAX_URL_LOGS_CHARS = 3 * 1024
MAX_PREVIEW_LOGS_CHARS = 3 * 1024
MAX_DOCTOR_SUMMARY_CHARS = 2 * 1024
DEDUP_TTL_SECONDS = 60
USER_COOLDOWN_SECONDS = 30 * 60
@@ -275,6 +276,53 @@ def build_prefill_url(
return f"{FEEDBACK_ISSUE_NEW_URL}?{encoded}"
def format_doctor_summary(doctor: Optional[dict[str, Any]]) -> str:
"""把 doctor JSON 报告压缩成适合 Issue 和预览展示的摘要。"""
if not isinstance(doctor, dict):
return "未收集到 doctor 报告。"
if not doctor.get("success"):
return f"doctor 收集失败:{doctor.get('error') or '未知错误'}"
report = doctor.get("report") or {}
if not isinstance(report, dict):
return "doctor 报告格式异常。"
lines = [
f"状态:{report.get('status') or 'unknown'}",
]
environment = report.get("environment") or {}
if isinstance(environment, dict):
runtime = environment.get("runtime")
if runtime:
lines.append(f"运行环境:{runtime}")
summary = report.get("summary") or {}
if isinstance(summary, dict):
lines.append(
"汇总:"
f"total={summary.get('total', 0)} "
f"error={summary.get('error', 0)} "
f"warn={summary.get('warn', 0)} "
f"fixed={summary.get('fixed', 0)}"
)
findings = report.get("findings") or []
if isinstance(findings, list):
important = [
item for item in findings
if isinstance(item, dict) and item.get("severity") in {"error", "warn"}
][:8]
if important:
lines.append("关键发现:")
for item in important:
title = str(item.get("title") or item.get("id") or "未知诊断项")
recommendation = str(item.get("recommendation") or "").strip()
line = f"- [{item.get('severity')}] {title}"
if recommendation:
line = f"{line};建议:{recommendation}"
lines.append(line)
return truncate("\n".join(lines), MAX_DOCTOR_SUMMARY_CHARS)
def classify_failure(status_code: Optional[int], headers: Optional[dict] = None) -> str:
"""把 GitHub API HTTP 状态码映射成脚本输出的稳定失败原因。"""
headers = headers or {}

View File

@@ -13,6 +13,7 @@ from feedback_issue_common import (
MAX_TITLE_CHARS,
build_issue_body,
check_content_quality,
format_doctor_summary,
load_diagnostics_logs,
read_json_file,
result_payload,
@@ -63,6 +64,7 @@ def validate_draft(draft: dict[str, Any], logs: str) -> Optional[str]:
def build_preview_text(draft: dict[str, Any], logs: str, diagnostics: dict[str, Any]) -> str:
"""构造给用户确认的 Markdown 预览文本。"""
preview_logs = sanitize_logs(logs, MAX_PREVIEW_LOGS_CHARS) or "会话中未捕获到相关后端日志。"
doctor_summary = format_doctor_summary(diagnostics.get("doctor"))
source_files = diagnostics.get("source_files") or []
sources = "\n".join(f"- {item}" for item in source_files) or "- 未命中具体日志文件"
return (
@@ -73,6 +75,8 @@ def build_preview_text(draft: dict[str, Any], logs: str, diagnostics: dict[str,
f"类型:{draft['issue_type']}\n\n"
"诊断来源:\n"
f"{sources}\n\n"
"Doctor 摘要:\n"
f"```text\n{doctor_summary}\n```\n\n"
"问题描述:\n"
f"{draft['description'].strip()}\n\n"
"日志预览(已脱敏):\n"
@@ -119,12 +123,18 @@ def prepare_issue(draft_file: str | Path) -> dict[str, Any]:
preview_text = build_preview_text(draft, logs, diagnostics)
preview_file.write_text(preview_text, encoding="utf-8")
combined_logs = "\n\n".join(
part for part in (
f"### Doctor 摘要\n{format_doctor_summary(diagnostics.get('doctor'))}",
logs,
) if part
)
body_preview = build_issue_body(
version=draft["version"],
environment=draft["environment"],
issue_type=draft["issue_type"],
description=draft["description"],
logs=logs,
logs=combined_logs,
)
return {
"success": True,

View File

@@ -19,6 +19,7 @@ from feedback_issue_common import (
check_recent_duplicate,
check_user_rate_limit,
classify_failure,
format_doctor_summary,
load_diagnostics_logs,
load_submission_state,
read_json_file,
@@ -150,7 +151,7 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
}
try:
logs, _ = load_diagnostics_logs(payload["diagnostics_file"])
logs, diagnostics = load_diagnostics_logs(payload["diagnostics_file"])
except Exception as err:
return {
"success": False,
@@ -166,12 +167,18 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
"message": error,
}
combined_logs = "\n\n".join(
part for part in (
f"### Doctor 摘要\n{format_doctor_summary(diagnostics.get('doctor'))}",
logs,
) if part
)
body = build_issue_body(
version=payload["version"],
environment=payload["environment"],
issue_type=payload["issue_type"],
description=payload["description"],
logs=logs,
logs=combined_logs,
)
state = load_submission_state()
if check_recent_duplicate(payload["title"], body, state):
@@ -186,7 +193,7 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
result = build_api_failure_result(
reason="rate_limited_user",
payload=payload,
logs=logs,
logs=combined_logs,
)
result["message"] = rate_error + " 如确实是另一个真实问题,请使用 prefill_url 手动提交。"
save_submission_state(state)
@@ -195,7 +202,7 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
record_user_submission(username, state)
if not settings.GITHUB_TOKEN:
save_submission_state(state)
return build_no_token_result(payload, logs)
return build_no_token_result(payload, combined_logs)
record_submission(payload["title"], body, state)
save_submission_state(state)
@@ -205,7 +212,7 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
return build_api_failure_result(
reason="network_error",
payload=payload,
logs=logs,
logs=combined_logs,
github_message=str(err),
)
@@ -213,7 +220,7 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
return build_api_failure_result(
reason="network_error",
payload=payload,
logs=logs,
logs=combined_logs,
)
if response.status_code == 201:
@@ -234,7 +241,7 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
return build_api_failure_result(
reason=reason,
payload=payload,
logs=logs,
logs=combined_logs,
github_message=api_message,
)