Preserve plugin diagnostics without degrading overall status

This commit is contained in:
jxxghp
2026-07-20 10:15:15 +08:00
parent d666134ed2
commit 405350c774
14 changed files with 270 additions and 23 deletions

View File

@@ -44,7 +44,9 @@ class QueryDoctorReportTool(MoviePilotTool):
description: str = (
"Run MoviePilot Doctor in read-only mode and return a structured diagnostic report for troubleshooting. "
"Use this tool when analyzing startup failures, Docker/runtime issues, port conflicts, dependency problems, "
"database health, frontend assets, safe mode, or recent log error clues. This tool never applies fixes."
"database health, frontend assets, safe mode, or recent log error clues. Plugin-only log findings remain "
"visible with affects_report_status=false and do not downgrade the overall status. This tool never applies "
"fixes."
)
require_admin: bool = True
args_schema: Type[BaseModel] = QueryDoctorReportInput
@@ -73,6 +75,7 @@ class QueryDoctorReportTool(MoviePilotTool):
"title": item.get("title"),
"fixable": item.get("fixable"),
"fixed": item.get("fixed"),
"affects_report_status": item.get("affects_report_status", True),
}
for item in report.get("findings") or []
if isinstance(item, dict)

View File

@@ -988,7 +988,7 @@ def logs(lines: int, follow: bool, stdio: bool, frontend_log: bool) -> None:
@click.option("--fix", is_flag=True, help="执行白名单安全修复")
@click.option("--deep", is_flag=True, help="执行可能较慢的深度检查")
def doctor(json_output: bool, fix: bool, deep: bool) -> None:
"""离线诊断本地 MoviePilot 运行环境"""
"""离线诊断本地 MoviePilot 运行环境,插件日志告警不影响整体状态"""
from app.doctor import run_doctor
from app.doctor.formatters import format_json_report, format_text_report

View File

@@ -45,6 +45,14 @@ LOG_ERROR_PATTERNS = (
re.compile(r"加载插件.+出错"),
re.compile(r"数据库更新失败"),
)
LOG_RECORD_PATTERN = re.compile(
r"(?:【(?:DEBUG|INFO|WARNING|ERROR|CRITICAL)】|(?:DEBUG|INFO|WARNING|ERROR|CRITICAL):)"
)
CONSOLE_LOGGER_PATTERN = re.compile(r"\[([^\]]+)]")
PLUGIN_ERROR_PATTERNS = (
re.compile(r"(?:^|\s-\s)plugin\.py\s+-\s", re.IGNORECASE),
re.compile(r"插件.+(?:出错|失败|异常|错误)"),
)
SENSITIVE_PATTERNS = (
re.compile(r"(?i)(api[_-]?token|token|password|secret|cookie)(\s*[:=]\s*)[^\s&]+"),
re.compile(r"\bghp_[A-Za-z0-9]{20,}\b"),
@@ -92,10 +100,23 @@ class DoctorRunnerProtocol:
recommendation: str,
fixable: bool = False,
fixed: bool = False,
affects_report_status: bool = True,
context: Optional[dict[str, Any]] = None,
) -> DoctorFinding:
"""
添加诊断发现。
:param finding_id: 诊断项稳定标识
:param severity: 诊断严重级别
:param status: 单项诊断状态
:param title: 诊断项标题
:param detail: 诊断详情
:param recommendation: 处理建议
:param fixable: 是否支持 Doctor 自动修复
:param fixed: 本次运行是否已修复
:param affects_report_status: 是否参与整体报告状态聚合
:param context: 可选结构化上下文
:return: 新增的诊断发现
"""
raise NotImplementedError
@@ -275,6 +296,7 @@ def _tail_lines(path: Path, max_lines: int = 120, max_bytes: int = 256 * 1024) -
def _find_error_lines(lines: list[str], max_matches: int = 12) -> list[str]:
"""从近期日志中提取错误关键词命中的行。"""
matches: list[str] = []
for line in lines:
if any(pattern.search(line) for pattern in LOG_ERROR_PATTERNS):
@@ -282,6 +304,39 @@ def _find_error_lines(lines: list[str], max_matches: int = 12) -> list[str]:
return matches[-max_matches:]
def _partition_error_lines(
lines: list[str],
plugin_logger_names: set[str],
max_matches: int = 12,
) -> tuple[list[str], list[str]]:
"""
将主日志错误线索拆分为核心错误和插件子系统错误。
:param lines: 近期日志行
:param plugin_logger_names: 已发现的插件控制台 logger 名称
:param max_matches: 每类最多保留的错误行数
:return: 核心错误行和插件错误行
"""
core_matches: list[str] = []
plugin_matches: list[str] = []
plugin_context = False
for line in lines:
if LOG_RECORD_PATTERN.search(line):
logger_match = CONSOLE_LOGGER_PATTERN.search(line)
console_logger = logger_match.group(1).strip().lower() if logger_match else ""
plugin_context = (
console_logger in plugin_logger_names
or any(pattern.search(line) for pattern in PLUGIN_ERROR_PATTERNS)
)
if not any(pattern.search(line) for pattern in LOG_ERROR_PATTERNS):
continue
if plugin_context or any(pattern.search(line) for pattern in PLUGIN_ERROR_PATTERNS):
plugin_matches.append(line)
else:
core_matches.append(line)
return core_matches[-max_matches:], plugin_matches[-max_matches:]
def _frontend_dir() -> Path:
root_public = settings.ROOT_PATH / "public"
configured = Path(settings.FRONTEND_PATH)
@@ -687,14 +742,18 @@ def _check_frontend_assets(runner: DoctorRunnerProtocol) -> None:
def _check_logs(runner: DoctorRunnerProtocol) -> None:
"""扫描近期日志,并区分核心运行异常与插件扩展异常。"""
log_files = [
_backend_app_log_file(),
_backend_stdio_log_file(),
_frontend_stdio_log_file(),
]
plugin_log_dir = settings.LOG_PATH / "plugins"
plugin_logger_names: set[str] = set()
if plugin_log_dir.exists():
log_files.extend(sorted(plugin_log_dir.rglob("*.log"))[:20])
plugin_log_files = sorted(plugin_log_dir.rglob("*.log"))
plugin_logger_names = {path.stem.lower() for path in plugin_log_files}
log_files.extend(plugin_log_files[:20])
found_any = False
for path in log_files:
@@ -702,23 +761,44 @@ def _check_logs(runner: DoctorRunnerProtocol) -> None:
continue
found_any = True
lines = _tail_lines(path)
errors = _find_error_lines(lines)
if not errors:
is_plugin_log = plugin_log_dir in path.parents
if is_plugin_log:
scoped_errors = [(True, _find_error_lines(lines))]
else:
core_errors, plugin_errors = _partition_error_lines(
lines,
plugin_logger_names,
)
scoped_errors = [(False, core_errors), (True, plugin_errors)]
if not any(errors for _, errors in scoped_errors):
continue
is_plugin = plugin_log_dir in path.parents
runner.add(
finding_id=f"logs.{path.stem}.recent_errors",
severity=DoctorSeverity.Warn,
status=DoctorFindingStatus.Degraded,
title="最近日志存在插件异常" if is_plugin else "最近日志存在错误线索",
detail="\n".join(errors),
recommendation=(
"可使用安全模式启动后检查插件配置。"
if is_plugin
else "结合前后的启动日志定位异常;必要时执行 `moviepilot doctor --json` 交给 Agent 或 Issue 流程。"
),
context={"log_file": str(path), "matches": len(errors)},
)
has_core_errors = bool(scoped_errors[0][1]) if not is_plugin_log else False
for is_plugin_error, errors in scoped_errors:
if not errors:
continue
finding_suffix = (
"plugin_errors"
if is_plugin_error and has_core_errors
else "recent_errors"
)
runner.add(
finding_id=f"logs.{path.stem}.{finding_suffix}",
severity=DoctorSeverity.Warn,
status=DoctorFindingStatus.Degraded,
title="最近日志存在插件异常" if is_plugin_error else "最近日志存在错误线索",
detail="\n".join(errors),
recommendation=(
"可使用安全模式启动后检查插件配置。"
if is_plugin_error
else "结合前后的启动日志定位异常;必要时执行 `moviepilot doctor --json` 交给 Agent 或 Issue 流程。"
),
affects_report_status=not is_plugin_error,
context={
"log_file": str(path),
"matches": len(errors),
"component": "plugin" if is_plugin_error else "core",
},
)
if not found_any:
runner.add(

View File

@@ -47,6 +47,8 @@ def _format_finding(finding: DoctorFinding) -> list[str]:
marker = finding.severity.value.upper()
if finding.fixed:
marker = "FIXED"
elif not finding.affects_report_status:
marker = f"{marker}/ADVISORY"
lines = [f"[{marker}] {finding.title}", f"ID: {finding.id}"]
if finding.detail:
lines.append(f"原因: {finding.detail}")

View File

@@ -52,6 +52,7 @@ class DoctorFinding:
recommendation: str
fixable: bool = False
fixed: bool = False
affects_report_status: bool = True
context: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
@@ -67,6 +68,7 @@ class DoctorFinding:
"recommendation": self.recommendation,
"fixable": self.fixable,
"fixed": self.fixed,
"affects_report_status": self.affects_report_status,
}
if self.context:
payload["context"] = self.context
@@ -90,7 +92,11 @@ class DoctorReport:
"""
根据诊断发现计算整体状态。
"""
unresolved = [finding for finding in self.findings if not finding.fixed]
unresolved = [
finding
for finding in self.findings
if not finding.fixed and finding.affects_report_status
]
if any(finding.severity == DoctorSeverity.Error for finding in unresolved):
return DoctorReportStatus.Failed
if any(finding.severity == DoctorSeverity.Warn for finding in unresolved):

View File

@@ -67,10 +67,23 @@ class DoctorRunner:
recommendation: str,
fixable: bool = False,
fixed: bool = False,
affects_report_status: bool = True,
context: Optional[dict[str, Any]] = None,
) -> DoctorFinding:
"""
添加诊断发现并返回该对象。
:param finding_id: 诊断项稳定标识
:param severity: 诊断严重级别
:param status: 单项诊断状态
:param title: 诊断项标题
:param detail: 诊断详情
:param recommendation: 处理建议
:param fixable: 是否支持 Doctor 自动修复
:param fixed: 本次运行是否已修复
:param affects_report_status: 是否参与整体报告状态聚合
:param context: 可选结构化上下文
:return: 新增的诊断发现
"""
finding = DoctorFinding(
id=finding_id,
@@ -81,6 +94,7 @@ class DoctorRunner:
recommendation=recommendation,
fixable=fixable,
fixed=fixed,
affects_report_status=affects_report_status,
context=context or {},
)
self.report.add_finding(finding)
@@ -88,6 +102,7 @@ class DoctorRunner:
@staticmethod
def _environment() -> dict[str, Any]:
"""收集 Doctor 报告所需的本地运行环境信息。"""
return {
"runtime": "Docker" if SystemUtils.is_docker() else platform.system(),
"platform": platform.platform(),

View File

@@ -392,6 +392,7 @@ moviepilot doctor --deep
- `--json` 输出稳定 JSON可供 Agent、脚本或 Issue 流程收集
- `--fix` 只执行白名单安全修复,例如清理过期 runtime 文件或补齐不合法的 `API_TOKEN`
- `--deep` 执行可能较慢的深度探测,例如 PostgreSQL TCP 连通性检查
- 插件日志异常会保留为诊断告警并标记 `affects_report_status=false`,但不会单独降低系统整体状态;核心错误仍正常参与状态聚合
- Docker 环境可使用 `docker exec <container> moviepilot doctor`;如果容器已退出,也可用镜像挂载同一配置目录运行 `python -m app.cli doctor`
日志:

View File

@@ -46,6 +46,8 @@ Doctor 默认执行只读检查:
`--deep` 会启用可能较慢或更依赖环境的检查,例如 PostgreSQL TCP 连通性。
整体状态只聚合会影响 MoviePilot 核心运行的诊断项。插件独立日志以及主日志中可明确识别的插件子系统异常仍会作为 `warn/degraded` 诊断项保留,但其 `affects_report_status``false`,不会单独把整体状态从 `healthy` 降为 `degraded`;同一日志中若还存在核心错误,核心错误仍会参与状态聚合。
## 自救能力
`moviepilot doctor --fix` 只做白名单安全修复:

View File

@@ -243,6 +243,10 @@ Agent 自主任务工具使用数据库中的整数 `task_id`。`query_scheduler
]
```
#### 系统诊断工具
`query_doctor_report` 以只读方式返回 MoviePilot Doctor 诊断报告,可通过 `deep` 启用深度检查,并通过 `include_details` 控制是否返回完整详情。每条诊断项的 `affects_report_status` 表示其是否参与整体状态聚合;插件日志异常会保留为 `warn/degraded` 线索,但该字段为 `false`,不会单独把系统整体状态降为 `degraded`
### 2. 调用工具
**POST** `/api/v1/mcp/tools/call`

View File

@@ -249,6 +249,7 @@ Options:
说明:
- doctor 是离线诊断入口,不依赖后端服务已经启动
- 插件日志异常会保留为告警,但不会单独降低系统整体状态
- Docker 环境可执行 docker exec <container> moviepilot doctor
EOF
}

View File

@@ -120,7 +120,9 @@ you need to show the preview generated in the next step.
The collect script also runs `moviepilot doctor --json` or falls back to
`python -m app.cli doctor --json`, stores the structured doctor report
inside `diagnostics_file`, and later preview/submit steps include a
short doctor summary automatically.
short doctor summary automatically. Plugin-only log findings remain in
the report as diagnostic evidence with `affects_report_status=false`, so
they do not by themselves downgrade the overall MoviePilot status.
If `success=false` with `no_explicit_feedback_intent`, stop this skill
and return to local diagnosis.

View File

@@ -260,5 +260,6 @@ Use `air_date` to find a block of recently-aired episodes that likely correspond
## Error handling
Missing configuration or authentication failure: run `moviepilot doctor` to
verify the local MoviePilot installation and settings. Do not ask the user to
paste the API key into the prompt for local CLI usage.
verify the local MoviePilot installation and settings. Plugin-only log findings
remain visible but do not by themselves downgrade the overall Doctor status.
Do not ask the user to paste the API key into the prompt for local CLI usage.

View File

@@ -71,6 +71,7 @@ def test_query_doctor_report_returns_readonly_report():
assert payload["report"]["status"] == "degraded"
assert payload["report"]["environment"]["runtime"] == "Docker"
assert payload["report"]["findings"][0]["detail"] == "ERROR demo Cookie: <REDACTED>"
assert payload["report"]["findings"][0]["affects_report_status"] is True
run_doctor.assert_called_once_with(deep=True)
@@ -88,6 +89,7 @@ def test_query_doctor_report_compact_mode_omits_details():
finding = payload["report"]["findings"][0]
assert finding["id"] == "logs.moviepilot.recent_errors"
assert finding["title"] == "最近日志存在错误线索"
assert finding["affects_report_status"] is True
assert "detail" not in finding
assert "context" not in finding

View File

@@ -23,6 +23,7 @@ def test_doctor_report_has_stable_json_shape(tmp_path, monkeypatch):
assert payload["environment"]["config_path"] == str(tmp_path)
assert isinstance(payload["summary"]["total"], int)
assert isinstance(payload["findings"], list)
assert all("affects_report_status" in item for item in payload["findings"])
assert any(item["id"] == "runtime.paths" for item in payload["findings"])
@@ -86,3 +87,130 @@ def test_doctor_accepts_healthy_unmanaged_backend_port(monkeypatch):
assert finding.severity == DoctorSeverity.Info
assert finding.context["backend_version"] == "v2-test"
assert runner.report.find("port.backend_occupied") is None
def test_doctor_plugin_log_error_does_not_degrade_report(tmp_path, monkeypatch):
"""插件独立日志中的错误应保留告警,但不降低系统整体状态。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
plugin_log = settings.LOG_PATH / "plugins" / "demo.log"
plugin_log.parent.mkdir(parents=True, exist_ok=True)
plugin_log.write_text(
"【ERROR】2026-07-20 08:00:00 - demo.py - 插件任务执行异常\n",
encoding="utf-8",
)
runner = DoctorRunner()
checks._check_logs(runner)
finding = runner.report.find("logs.demo.recent_errors")
assert finding is not None
assert finding.status == DoctorFindingStatus.Degraded
assert finding.severity == DoctorSeverity.Warn
assert finding.affects_report_status is False
assert finding.context["component"] == "plugin"
assert runner.report.status.value == "healthy"
assert "[WARN/ADVISORY]" in format_text_report(runner.report)
def test_doctor_plugin_load_error_in_main_log_does_not_degrade_report(
tmp_path,
monkeypatch,
):
"""主日志中的插件加载异常应归入插件告警,不降低系统整体状态。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
app_log = settings.LOG_PATH / "moviepilot.log"
app_log.parent.mkdir(parents=True, exist_ok=True)
app_log.write_text(
"【ERROR】2026-07-20 08:00:00 - plugin.py - 加载插件 Demo 出错boom - Traceback (most recent call last):\n"
"Exception: boom\n",
encoding="utf-8",
)
runner = DoctorRunner()
checks._check_logs(runner)
finding = runner.report.find("logs.moviepilot.recent_errors")
assert finding is not None
assert finding.affects_report_status is False
assert finding.context["component"] == "plugin"
assert "Exception: boom" in finding.detail
assert runner.report.status.value == "healthy"
def test_doctor_plugin_error_mirrored_to_stdio_does_not_degrade_report(
tmp_path,
monkeypatch,
):
"""插件错误镜像到后端 stdio 日志时仍不应降低系统整体状态。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
plugin_log = settings.LOG_PATH / "plugins" / "DemoPlugin.log"
plugin_log.parent.mkdir(parents=True, exist_ok=True)
plugin_log.write_text(
"【INFO】2026-07-20 08:00:00 - demo.py - 插件已启动\n",
encoding="utf-8",
)
stdio_log = settings.LOG_PATH / "moviepilot.stdout.log"
stdio_log.write_text(
"ERROR: [demoplugin] 2026-07-20 08:01:00 demo.py - task exception\n",
encoding="utf-8",
)
runner = DoctorRunner()
checks._check_logs(runner)
finding = runner.report.find("logs.moviepilot.stdout.recent_errors")
assert finding is not None
assert finding.affects_report_status is False
assert finding.context["component"] == "plugin"
assert runner.report.status.value == "healthy"
def test_doctor_core_log_error_still_degrades_report(tmp_path, monkeypatch):
"""核心日志错误仍应参与系统整体状态聚合。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
app_log = settings.LOG_PATH / "moviepilot.log"
app_log.parent.mkdir(parents=True, exist_ok=True)
app_log.write_text(
"【ERROR】2026-07-20 08:00:00 - rss.py - 解析 RSS 失败 - Traceback (most recent call last):\n"
"RuntimeError: boom\n",
encoding="utf-8",
)
runner = DoctorRunner()
checks._check_logs(runner)
finding = runner.report.find("logs.moviepilot.recent_errors")
assert finding is not None
assert finding.affects_report_status is True
assert finding.context["component"] == "core"
assert runner.report.status.value == "degraded"
def test_doctor_mixed_plugin_and_core_log_errors_keep_core_status(
tmp_path,
monkeypatch,
):
"""同一主日志混有插件和核心错误时,仅核心错误应影响整体状态。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
app_log = settings.LOG_PATH / "moviepilot.log"
app_log.parent.mkdir(parents=True, exist_ok=True)
app_log.write_text(
"【ERROR】2026-07-20 08:00:00 - plugin.py - 加载插件 Demo 出错boom - Traceback (most recent call last):\n"
"Exception: plugin boom\n"
"【ERROR】2026-07-20 08:01:00 - rss.py - 解析 RSS 失败 - Traceback (most recent call last):\n"
"Exception: core boom\n",
encoding="utf-8",
)
runner = DoctorRunner()
checks._check_logs(runner)
core_finding = runner.report.find("logs.moviepilot.recent_errors")
plugin_finding = runner.report.find("logs.moviepilot.plugin_errors")
assert core_finding is not None
assert plugin_finding is not None
assert core_finding.affects_report_status is True
assert plugin_finding.affects_report_status is False
assert "core boom" in core_finding.detail
assert "plugin boom" in plugin_finding.detail
assert runner.report.status.value == "degraded"