refactor(agent): move feedback issue flow into skill scripts

This commit is contained in:
jxxghp
2026-05-21 19:22:27 +08:00
parent b6b5529d19
commit 737bcb5c62
16 changed files with 1683 additions and 4242 deletions

View File

@@ -0,0 +1,308 @@
"""收集 feedback-issue 提交流程需要的本地诊断日志。"""
from __future__ import annotations
import argparse
import re
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
from feedback_issue_common import (
MAX_LOGS_CHARS,
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+"
)
_META_NOISE_MODULES = frozenset({
"collect_feedback_diagnostics.py",
"prepare_feedback_issue.py",
"submit_feedback_issue.py",
"ask_user_choice.py",
"base.py",
"agent",
"factory.py",
"callback",
"prompt",
"memory.py",
"activity_log.py",
"message.py",
"event.py",
"chain",
"discord",
"telegram",
"telegram.py",
"execute_command.py",
})
_VAGUE_KEYWORDS = frozenset({
"错误", "异常", "失败", "error", "exception", "failed", "warn", "warning",
"日志", "问题", "bug", "log", "logs",
})
_FEEDBACK_VERB_PHRASES: tuple[str, ...] = (
"反馈", "提交", "上报", "汇报",
"提 issue", "提issue", "提 bug", "提bug",
"报 bug", "报bug", "报告 bug", "报告bug",
"新建 issue", "新建issue", "开 issue", "开issue",
"让上游", "给上游",
"file an issue", "report a bug", "open an upstream issue",
"submit an issue", "raise an issue", "report this upstream",
"report upstream",
)
_FEEDBACK_TARGET_TOKENS: tuple[str, ...] = (
"issue", "bug", "问题", "错误报告",
"上游", "mp", "moviepilot",
)
_FEEDBACK_STANDALONE_PHRASES: tuple[str, ...] = (
"file an issue", "report a bug", "open an upstream issue",
"submit an issue", "raise an issue", "report this upstream",
"report upstream",
"新建 issue", "新建issue", "开 issue", "开issue",
"提 issue", "提issue", "提 bug", "提bug",
"报 bug", "报bug", "报告 bug", "报告bug",
"让上游", "给上游",
)
_FEEDBACK_REGEX_PATTERNS: tuple[re.Pattern, ...] = (
re.compile(r"提.{0,6}(bug|issue|问题|错误报告)", re.IGNORECASE),
re.compile(r"报.{0,6}(bug|issue|错误报告)", re.IGNORECASE),
re.compile(r"反馈.{0,8}(issue|bug|问题|上游|错误)", re.IGNORECASE),
re.compile(r"开.{0,4}(issue|bug)", re.IGNORECASE),
re.compile(r"上报.{0,6}(bug|issue|问题|错误)", re.IGNORECASE),
)
def read_tail(path: Path) -> 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 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 filter_lines(
text: str,
keywords: list[str],
max_lines: int,
window_start: datetime,
) -> list[str]:
"""按时间窗、模块噪音和关键词筛选日志行。"""
candidates: list[str] = []
last_seen_in_window: Optional[bool] = None
last_seen_was_meta = False
for line in text.splitlines():
if not line.strip():
continue
timestamp = parse_line_timestamp(line)
if timestamp is not None:
in_window = timestamp >= window_start
meta = is_meta_noise(line)
last_seen_was_meta = meta
last_seen_in_window = in_window and not meta
if in_window and not meta:
candidates.append(line)
elif last_seen_in_window and not last_seen_was_meta:
candidates.append(line)
if not candidates:
return []
if keywords:
lowered_keywords = [item.lower() for item in keywords]
matched: list[str] = []
keep_block = False
for line in candidates:
has_timestamp = parse_line_timestamp(line) is not None
if has_timestamp:
keep_block = any(keyword in line.lower() for keyword in lowered_keywords)
if keep_block:
matched.append(line)
elif keep_block:
matched.append(line)
if matched:
return matched[-max_lines:]
return candidates[-max_lines:]
def collect_diagnostics(
*,
original_user_request: str,
keywords: list[str],
max_lines: int,
time_window_minutes: int,
) -> dict:
"""读取日志、筛选、脱敏并写入运行时诊断文件。"""
if not has_explicit_feedback_intent(original_user_request):
return {
"success": False,
"reason": "no_explicit_feedback_intent",
"message": (
"用户原话里没有明确要求向上游反馈 Issue 的短语,"
"请先回到常规诊断路径;只有明确说出反馈 issue / 提 issue / 报 bug "
"等意图时才运行 feedback-issue 流程。"
),
}
normalized_max_lines = min(max(int(max_lines or 80), 20), 200)
window_minutes = normalize_window(time_window_minutes)
window_start = datetime.now() - timedelta(minutes=window_minutes)
normalized_keywords = normalize_keywords(keywords)
collected: list[str] = []
source_files: list[str] = []
for path in candidate_log_files():
text = read_tail(path)
if not text:
continue
lines = filter_lines(
text=text,
keywords=normalized_keywords,
max_lines=normalized_max_lines,
window_start=window_start,
)
if not lines:
continue
source_files.append(str(path))
collected.append(f"### {path.name}\n" + "\n".join(lines))
logs = sanitize_logs("\n\n".join(collected), MAX_LOGS_CHARS)
diagnostics_file = runtime_file("diagnostics", ".json")
diagnostics = {
"original_user_request": original_user_request,
"keywords": normalized_keywords,
"found": bool(logs.strip()),
"logs": logs,
"source_files": source_files,
"created_at": datetime.now().isoformat(timespec="seconds"),
}
write_json_file(diagnostics_file, diagnostics)
return {
"success": True,
"found": diagnostics["found"],
"diagnostics_file": str(diagnostics_file),
"runtime_dir": str(feedback_runtime_dir()),
"source_files": source_files,
"log_bytes": len(logs.encode("utf-8", errors="replace")),
"log_lines": len(logs.splitlines()) if logs else 0,
"message": (
"已收集并写入反馈诊断日志文件。"
if logs
else "已完成诊断日志收集,但未找到明显相关日志。"
),
}
def parse_args() -> argparse.Namespace:
"""解析命令行参数。"""
parser = argparse.ArgumentParser(description="收集 MoviePilot 反馈 Issue 诊断日志")
parser.add_argument("--original-user-request", required=True, help="触发反馈的用户原话")
parser.add_argument("--keyword", action="append", default=[], help="用于过滤日志的具体关键词,可重复")
parser.add_argument("--max-lines", type=int, default=80, help="最多保留的日志行数")
parser.add_argument(
"--time-window-minutes",
type=int,
default=_DEFAULT_TIME_WINDOW_MINUTES,
help="只收集最近 N 分钟日志,默认 30",
)
return parser.parse_args()
def main() -> int:
"""脚本入口:输出 JSON 结果给 Agent 解析。"""
args = parse_args()
result = collect_diagnostics(
original_user_request=args.original_user_request,
keywords=args.keyword,
max_lines=args.max_lines,
time_window_minutes=args.time_window_minutes,
)
print(result_payload(**result))
return 0 if result.get("success") else 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,494 @@
"""feedback-issue skill 脚本共享逻辑。"""
from __future__ import annotations
import hashlib
import json
import re
import sys
import time
import uuid
from pathlib import Path
from typing import Any, Optional
from urllib.parse import quote
def _find_repo_root() -> Path:
"""从当前工作目录和脚本路径向上查找 MoviePilot 仓库根目录。"""
script_path = Path(__file__).resolve()
candidates = [Path.cwd().resolve(), *Path.cwd().resolve().parents]
candidates.extend([script_path.parent, *script_path.parents])
for candidate in candidates:
if (candidate / "app" / "core" / "config.py").is_file():
return candidate
return script_path.parents[3]
REPO_ROOT = _find_repo_root()
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from app.core.config import settings # noqa: E402
FEEDBACK_REPO_OWNER = "jxxghp"
FEEDBACK_REPO_NAME = "MoviePilot"
FEEDBACK_REPO = f"{FEEDBACK_REPO_OWNER}/{FEEDBACK_REPO_NAME}"
FEEDBACK_ISSUE_API = f"https://api.github.com/repos/{FEEDBACK_REPO}/issues"
FEEDBACK_ISSUE_NEW_URL = f"https://github.com/{FEEDBACK_REPO}/issues/new"
FEEDBACK_ISSUE_TEMPLATE = "bug_report.yml"
FEEDBACK_REQUEST_TIMEOUT = 15
ALLOWED_ENVIRONMENTS = ("Docker", "Windows")
ALLOWED_ISSUE_TYPES = ("主程序运行问题", "插件问题", "其他问题")
MAX_TITLE_CHARS = 256
MAX_BODY_CHARS = 60 * 1024
MAX_LOGS_CHARS = 8 * 1024
MAX_URL_LOGS_CHARS = 3 * 1024
MAX_PREVIEW_LOGS_CHARS = 3 * 1024
DEDUP_TTL_SECONDS = 60
USER_COOLDOWN_SECONDS = 30 * 60
USER_DAILY_QUOTA = 10
USER_DAILY_WINDOW_SECONDS = 24 * 60 * 60
MAX_USER_SUBMISSIONS_BUCKETS = 200
MIN_TITLE_BODY_CHARS = 8
MIN_DESCRIPTION_CHARS = 50
TITLE_PREFIX = "[错误报告]:"
_QUALITY_BLOCKLIST = (
"测试issue", "测试 issue", "test issue",
"test123", "testtest", "测试测试",
"测试一下", "测试提交", "测试请求", "测试反馈",
"看能否跑通", "能否跑通", "跑通流程", "链路测试",
"模拟问题", "模拟问题描述", "模拟描述", "模拟 bug", "模拟bug",
"编造", "虚假 bug", "虚假bug",
"asdf", "asdfasdf", "qwer", "qwerty", "qweqwe",
"占位", "占个坑", "随便", "随便写",
"abcabc", "xxxxxx", "xxx xxx",
"hello world", "你好世界",
"lorem ipsum", "dolor sit amet",
)
_FABRICATED_LOG_PHRASES = (
"无相关日志", "没有相关日志", "未捕获到相关日志",
"这是模拟", "模拟问题", "模拟描述", "用户反馈",
)
_DESCRIPTION_REQUIRED_SIGNALS = (
("现象", ("现象", "报错", "错误", "无法", "失败", "异常")),
("复现步骤", ("复现", "步骤", "触发", "操作", "调用", "点击")),
("期望行为", ("期望", "应该", "预期", "正常")),
)
_REPEAT_GIBBERISH = re.compile(r"([^\s=\-_*#~`./\\+|])\1{7,}", re.UNICODE)
_REDACTED = "<REDACTED>"
_REDACTED_PATH = "/<USER>/"
_REDACTED_EMAIL = "<EMAIL>"
_REDACTED_IP = "<IP>"
_SENSITIVE_PATTERNS: tuple[tuple[re.Pattern, str], ...] = (
(re.compile(r"(?i)(Cookie\s*:\s*)[^\r\n]+"), rf"\1{_REDACTED}"),
(re.compile(r"(?i)(Set-Cookie\s*:\s*)[^\r\n]+"), rf"\1{_REDACTED}"),
(
re.compile(r"(?i)(Authorization\s*:\s*)(Bearer|Basic|Token)\s+\S+"),
rf"\1\2 {_REDACTED}",
),
(re.compile(r"(?i)(X-(?:Api-Key|Auth-Token|Access-Token)\s*:\s*)\S+"), rf"\1{_REDACTED}"),
(re.compile(r"\bghp_[A-Za-z0-9]{20,}\b"), _REDACTED),
(re.compile(r"\bgho_[A-Za-z0-9]{20,}\b"), _REDACTED),
(re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"), _REDACTED),
(re.compile(r"\b(sk|xoxb|xoxp|xoxa)-[A-Za-z0-9-]{12,}\b"), _REDACTED),
(re.compile(r"\buser_\d{4,}_\d+\b"), _REDACTED),
(re.compile(r"(?i)\b(passkey|rsskey|authkey|access_key)=[A-Za-z0-9]{8,}"), rf"\1={_REDACTED}"),
(
re.compile(
r"https?://(qyapi\.weixin\.qq\.com|oapi\.dingtalk\.com|open\.feishu\.cn|"
r"hooks\.slack\.com|discord(?:app)?\.com/api/webhooks)/\S+"
),
rf"\1/{_REDACTED}",
),
(
re.compile(
r"(?i)\b("
r"api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|id[_-]?token|"
r"client[_-]?secret|client[_-]?id|app[_-]?secret|app[_-]?key|"
r"corp[_-]?secret|corp[_-]?id|agent[_-]?id|"
r"password|secret|token|auth|credential|"
r"chat[_-]?id|webhook|api[_-]?token|bot[_-]?token|"
r"user[_-]?id|userid|username|user[_-]?name|"
r"session[_-]?id|sessionid|"
r"open[_-]?id|openid|union[_-]?id|unionid"
r")(\s*[:=]\s*)['\"]?[^\s'\"&\r\n]{2,}"
),
rf"\1\2{_REDACTED}",
),
(
re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}"),
_REDACTED_EMAIL,
),
(
re.compile(
r"\b(?!(?:127|10)\.)"
r"(?!172\.(?:1[6-9]|2\d|3[01])\.)"
r"(?!192\.168\.)"
r"(?:\d{1,3}\.){3}\d{1,3}\b"
),
_REDACTED_IP,
),
(re.compile(r"/Users/[^/\s]+/"), _REDACTED_PATH),
(re.compile(r"/home/[^/\s]+/"), _REDACTED_PATH),
(re.compile(r"C:\\Users\\[^\\\s]+\\", re.IGNORECASE), r"C:\\Users\\<USER>\\"),
)
def feedback_runtime_dir() -> Path:
"""返回 feedback-issue 脚本使用的运行时目录并确保存在。"""
runtime_dir = settings.TEMP_PATH / "feedback-issue"
runtime_dir.mkdir(parents=True, exist_ok=True)
return runtime_dir
def runtime_file(prefix: str, suffix: str) -> Path:
"""在运行时目录下生成一个随机文件路径。"""
safe_prefix = re.sub(r"[^a-zA-Z0-9_-]+", "-", prefix).strip("-") or "feedback"
return feedback_runtime_dir() / f"{safe_prefix}-{uuid.uuid4().hex[:12]}{suffix}"
def ensure_runtime_file(path: str | Path) -> Path:
"""校验脚本间传递的文件必须位于 feedback-issue 运行时目录内。"""
candidate = Path(path).expanduser().resolve()
runtime_dir = feedback_runtime_dir().resolve()
if not candidate.is_relative_to(runtime_dir):
raise ValueError(f"只允许读取 feedback-issue 运行时目录内的文件: {candidate}")
return candidate
def read_json_file(path: str | Path) -> dict[str, Any]:
"""读取 JSON 文件并确保顶层对象是 dict。"""
json_path = Path(path).expanduser()
data = json.loads(json_path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(f"JSON 顶层必须是对象: {json_path}")
return data
def write_json_file(path: str | Path, payload: dict[str, Any]) -> Path:
"""把 JSON 对象写入文件并返回实际路径。"""
json_path = Path(path)
json_path.parent.mkdir(parents=True, exist_ok=True)
json_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return json_path
def validate_enum(value: str, allowed: tuple[str, ...], field_name: str) -> Optional[str]:
"""校验枚举字段,返回错误信息;通过时返回 None。"""
if value not in allowed:
return (
f"{field_name} 必须是以下之一:{', '.join(allowed)}"
f"当前传入:{value!r}"
)
return None
def redact_logs(raw: str) -> str:
"""对日志文本做统一脱敏,覆盖常见 token、Cookie、PII 和本机路径。"""
out = raw
for pattern, replacement in _SENSITIVE_PATTERNS:
out = pattern.sub(replacement, out)
return out
def truncate(text: str, limit: int, marker: str = "\n...(已截断)") -> str:
"""按字符数截断文本并附加截断标记。"""
if not text or len(text) <= limit:
return text
return text[: max(0, limit - len(marker))] + marker
def sanitize_logs(logs: Optional[str], limit: int) -> str:
"""清洗日志:去空白、脱敏并按指定长度截断。"""
if not logs or not logs.strip():
return ""
return truncate(redact_logs(logs.strip()), limit)
def build_issue_body(
*,
version: str,
environment: str,
issue_type: str,
description: str,
logs: Optional[str],
) -> str:
"""构造与上游 bug_report.yml 表单渲染接近的 Issue Markdown 正文。"""
log_block = sanitize_logs(logs, MAX_LOGS_CHARS) or "会话中未捕获到相关后端日志。"
body = (
"### 确认\n\n"
"- [x] 我的版本是最新版本,我的版本号与 "
"[version](https://github.com/jxxghp/MoviePilot/releases/latest) 相同。\n"
"- [x] 我已经 [issue](https://github.com/jxxghp/MoviePilot/issues) "
"中搜索过,确认我的问题没有被提出过。\n"
"- [x] 我已经 [Telegram频道](https://t.me/moviepilot_channel) "
"中搜索过,确认我的问题没有被提出过。\n"
"- [x] 我已经修改标题,将标题中的 描述 替换为我遇到的问题。\n\n"
f"### 当前程序版本\n\n{version}\n\n"
f"### 运行环境\n\n{environment}\n\n"
f"### 问题类型\n\n{issue_type}\n\n"
f"### 问题描述\n\n{description.strip()}\n\n"
"### 发生问题时系统日志和配置文件\n\n"
f"```bash\n{log_block}\n```\n"
"\n---\n"
"_本 Issue 由 MoviePilot Agent 协助用户提交。_"
)
return truncate(body, MAX_BODY_CHARS)
def build_prefill_url(
*,
title: str,
version: str,
environment: str,
issue_type: str,
description: str,
logs: Optional[str],
) -> str:
"""生成 GitHub Issue Forms 预填 URL供无 token 或 API 失败时手动提交。"""
params = {
"template": FEEDBACK_ISSUE_TEMPLATE,
"title": title,
"version": version,
"environment": environment,
"type": issue_type,
"what-happened": description,
"logs": sanitize_logs(logs, MAX_URL_LOGS_CHARS),
}
encoded = "&".join(
f"{quote(k, safe='')}={quote(v, safe='')}" for k, v in params.items()
)
return f"{FEEDBACK_ISSUE_NEW_URL}?{encoded}"
def classify_failure(status_code: Optional[int], headers: Optional[dict] = None) -> str:
"""把 GitHub API HTTP 状态码映射成脚本输出的稳定失败原因。"""
headers = headers or {}
if status_code == 401:
return "no_permission"
if status_code == 403:
remaining = headers.get("X-RateLimit-Remaining") or headers.get(
"x-ratelimit-remaining"
)
if remaining == "0":
return "rate_limited"
return "no_permission"
if status_code == 404:
return "no_permission"
if status_code == 422:
return "invalid_payload"
if status_code is not None and status_code >= 500:
return "github_unavailable"
return "api_error"
def safe_response_dict(response: Any) -> dict[str, Any]:
"""安全解析 HTTP 响应 JSON非 dict 或解析失败时返回空 dict。"""
try:
data = response.json()
except Exception:
return {}
if isinstance(data, dict):
return data
return {}
def check_content_quality(
*,
title: str,
description: str,
original_user_request: str,
logs: Optional[str] = None,
) -> Optional[str]:
"""检查 Issue 内容质量,拦截测试、占位、乱码和结构缺失的提交。"""
original_stripped = (original_user_request or "").strip()
if not original_stripped:
return (
"缺少原始用户请求,无法判断本次提交是否来自真实故障。"
"请传入触发反馈的用户原话,不能只传改写后的 Issue 草稿。"
)
title_body = title.strip()
if title_body.startswith(TITLE_PREFIX):
title_body = title_body[len(TITLE_PREFIX):].strip()
if len(title_body) < MIN_TITLE_BODY_CHARS:
return (
f"标题正文太短(剔除 {TITLE_PREFIX!r} 前缀后只有 {len(title_body)} 字,"
f"至少 {MIN_TITLE_BODY_CHARS} 字)。请用一句完整的话概括症状。"
)
desc_stripped = description.strip()
if len(desc_stripped) < MIN_DESCRIPTION_CHARS:
return (
f"问题描述太短({len(desc_stripped)} 字,至少 {MIN_DESCRIPTION_CHARS} 字)。"
"请补充:现象 / 复现步骤 / 期望行为。"
)
missing_signals = [
label
for label, choices in _DESCRIPTION_REQUIRED_SIGNALS
if not any(choice in desc_stripped for choice in choices)
]
if missing_signals:
return (
"问题描述缺少可复现 bug 所需的结构信息:"
f"{' / '.join(missing_signals)}。请补充真实现象、触发步骤和期望行为。"
)
haystack = "\n".join(
part for part in (title, description, original_stripped) if part
).lower()
for phrase in _QUALITY_BLOCKLIST:
if phrase.lower() in haystack:
return (
f"原始请求、标题或描述命中明显占位/测试关键词「{phrase}」,"
"已拒绝提交。如果是真实问题,请用正常的中文描述具体现象。"
)
match = (
_REPEAT_GIBBERISH.search(title)
or _REPEAT_GIBBERISH.search(description)
or _REPEAT_GIBBERISH.search(original_stripped)
)
if match:
return (
f"标题或描述里出现疑似乱码片段「{match.group(0)[:12]}...」,"
"请用正常文字描述问题。"
)
log_text = (logs or "").strip()
if log_text:
lowered_logs = log_text.lower()
for phrase in _FABRICATED_LOG_PHRASES:
if phrase.lower() in lowered_logs and len(log_text) < 200:
return (
f"日志字段疑似填入了叙述性占位内容「{phrase}」,"
"请只提交真实日志;没有日志时留空。"
)
return None
def normalize_username(username: Optional[str]) -> str:
"""归一化用户名,作为脚本级提交频率限制的桶 key。"""
return (username or "").strip().lower()
def load_submission_state() -> dict[str, Any]:
"""读取脚本持久化的短期提交状态。"""
state_file = feedback_runtime_dir() / "submission-state.json"
if not state_file.exists():
return {"recent_submissions": {}, "user_submissions": {}}
try:
state = read_json_file(state_file)
except Exception:
return {"recent_submissions": {}, "user_submissions": {}}
state.setdefault("recent_submissions", {})
state.setdefault("user_submissions", {})
return state
def save_submission_state(state: dict[str, Any]) -> None:
"""写回脚本持久化的短期提交状态。"""
write_json_file(feedback_runtime_dir() / "submission-state.json", state)
def check_recent_duplicate(title: str, body: str, state: dict[str, Any]) -> Optional[str]:
"""检查 60 秒内是否提交过同 title + body 的内容。"""
now = time.time()
recent = state.setdefault("recent_submissions", {})
for key, ts in list(recent.items()):
if now - float(ts or 0) > DEDUP_TTL_SECONDS:
recent.pop(key, None)
key = hashlib.sha256(f"{title}\x00{body}".encode("utf-8", errors="replace")).hexdigest()
if key in recent:
return key
return None
def record_submission(title: str, body: str, state: dict[str, Any]) -> None:
"""记录一次提交内容摘要,供短时间去重使用。"""
key = hashlib.sha256(f"{title}\x00{body}".encode("utf-8", errors="replace")).hexdigest()
state.setdefault("recent_submissions", {})[key] = time.time()
def evict_user_submissions_if_needed(state: dict[str, Any]) -> None:
"""限制用户提交状态桶数量,避免运行时文件无限增长。"""
buckets = state.setdefault("user_submissions", {})
if len(buckets) <= MAX_USER_SUBMISSIONS_BUCKETS:
return
excess = len(buckets) - MAX_USER_SUBMISSIONS_BUCKETS
oldest_keys = sorted(
buckets.items(),
key=lambda kv: kv[1][-1] if kv[1] else 0,
)[:excess]
for key, _ in oldest_keys:
buckets.pop(key, None)
def check_user_rate_limit(username: str, state: dict[str, Any]) -> Optional[str]:
"""检查单用户 30 分钟冷却和 24 小时提交配额。"""
key = normalize_username(username)
if not key:
return "无法识别调用用户身份rate limit 拒绝以防误用。"
now = time.time()
buckets = state.setdefault("user_submissions", {})
timestamps = buckets.get(key, [])
active = [float(ts) for ts in timestamps if now - float(ts or 0) < USER_DAILY_WINDOW_SECONDS]
if active:
buckets[key] = active
else:
buckets.pop(key, None)
if active:
since_last = now - active[-1]
if since_last < USER_COOLDOWN_SECONDS:
remaining = int(USER_COOLDOWN_SECONDS - since_last)
minutes, seconds = divmod(remaining, 60)
return (
f"为避免给上游刷屏,同一管理员两次提交之间至少间隔 "
f"{USER_COOLDOWN_SECONDS // 60} 分钟。请等 {minutes}{seconds} 秒后再试。"
)
if len(active) >= USER_DAILY_QUOTA:
recover_in = int(USER_DAILY_WINDOW_SECONDS - (now - active[0]))
hours, remainder = divmod(recover_in, 3600)
minutes = remainder // 60
return (
f"你今日已提交 {USER_DAILY_QUOTA} 个 Issue已达 24 小时配额上限。"
f"最早一条将在 {hours} 小时 {minutes} 分钟后过期,请到时再提。"
)
return None
def record_user_submission(username: str, state: dict[str, Any]) -> None:
"""记录一次用户级提交时间戳,供冷却和配额检查使用。"""
key = normalize_username(username)
if not key:
return
state.setdefault("user_submissions", {}).setdefault(key, []).append(time.time())
evict_user_submissions_if_needed(state)
def load_diagnostics_logs(diagnostics_file: str | Path) -> tuple[str, dict[str, Any]]:
"""读取诊断文件中的日志正文并返回日志与诊断元数据。"""
path = ensure_runtime_file(diagnostics_file)
data = read_json_file(path)
logs = sanitize_logs(str(data.get("logs") or ""), MAX_LOGS_CHARS)
return logs, data
def result_payload(**fields: Any) -> str:
"""把脚本结果格式化为 Agent 容易解析的 JSON 字符串。"""
return json.dumps(fields, ensure_ascii=False, indent=2)

View File

@@ -0,0 +1,159 @@
"""校验并生成 feedback-issue 提交前的预览与 payload 文件。"""
from __future__ import annotations
import argparse
from pathlib import Path
from typing import Any, Optional
from feedback_issue_common import (
ALLOWED_ENVIRONMENTS,
ALLOWED_ISSUE_TYPES,
MAX_PREVIEW_LOGS_CHARS,
MAX_TITLE_CHARS,
build_issue_body,
check_content_quality,
load_diagnostics_logs,
read_json_file,
result_payload,
runtime_file,
sanitize_logs,
truncate,
validate_enum,
write_json_file,
)
REQUIRED_DRAFT_FIELDS = (
"title",
"version",
"environment",
"issue_type",
"description",
"original_user_request",
"diagnostics_file",
)
def normalize_draft(raw: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
"""规范化草稿字段并返回缺失字段列表。"""
draft = {key: str(raw.get(key) or "").strip() for key in REQUIRED_DRAFT_FIELDS}
missing = [key for key, value in draft.items() if not value]
draft["title"] = truncate(draft["title"], MAX_TITLE_CHARS, marker="...")
return draft, missing
def validate_draft(draft: dict[str, Any], logs: str) -> Optional[str]:
"""校验草稿枚举和内容质量,返回错误信息或 None。"""
for value, allowed, field_name in (
(draft["environment"], ALLOWED_ENVIRONMENTS, "environment"),
(draft["issue_type"], ALLOWED_ISSUE_TYPES, "issue_type"),
):
error = validate_enum(value, allowed, field_name)
if error:
return error
return check_content_quality(
title=draft["title"],
description=draft["description"],
original_user_request=draft["original_user_request"],
logs=logs,
)
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 "会话中未捕获到相关后端日志。"
source_files = diagnostics.get("source_files") or []
sources = "\n".join(f"- {item}" for item in source_files) or "- 未命中具体日志文件"
return (
"请确认是否提交以下问题反馈:\n\n"
f"标题:{draft['title']}\n"
f"版本:{draft['version']}\n"
f"环境:{draft['environment']}\n"
f"类型:{draft['issue_type']}\n\n"
"诊断来源:\n"
f"{sources}\n\n"
"问题描述:\n"
f"{draft['description'].strip()}\n\n"
"日志预览(已脱敏):\n"
f"```bash\n{preview_logs}\n```\n\n"
"如内容无误,请回复「确认」;如需调整,请回复「修改:...」。"
)
def prepare_issue(draft_file: str | Path) -> dict[str, Any]:
"""读取草稿 JSON校验后写出 payload 与 preview 文件。"""
raw = read_json_file(draft_file)
draft, missing = normalize_draft(raw)
if missing:
return {
"success": False,
"reason": "missing_fields",
"message": f"草稿缺少必填字段:{', '.join(missing)}",
}
try:
logs, diagnostics = load_diagnostics_logs(draft["diagnostics_file"])
except Exception as err:
return {
"success": False,
"reason": "diagnostics_missing",
"message": f"无法读取诊断日志文件:{err}",
}
error = validate_draft(draft, logs)
if error:
return {
"success": False,
"reason": "invalid_draft",
"message": error,
}
payload = {
**draft,
"diagnostics_file": str(draft["diagnostics_file"]),
}
payload_file = runtime_file("payload", ".json")
preview_file = runtime_file("preview", ".md")
write_json_file(payload_file, payload)
preview_text = build_preview_text(draft, logs, diagnostics)
preview_file.write_text(preview_text, encoding="utf-8")
body_preview = build_issue_body(
version=draft["version"],
environment=draft["environment"],
issue_type=draft["issue_type"],
description=draft["description"],
logs=logs,
)
return {
"success": True,
"payload_file": str(payload_file),
"preview_file": str(preview_file),
"body_chars": len(body_preview),
"log_bytes": len(logs.encode("utf-8", errors="replace")),
"log_lines": len(logs.splitlines()) if logs else 0,
"message": (
"已生成 Issue 预览和提交 payload。请把 preview_file 内容完整展示给用户,"
"等待明确「确认」后再调用 submit_feedback_issue.py。"
),
}
def parse_args() -> argparse.Namespace:
"""解析命令行参数。"""
parser = argparse.ArgumentParser(description="生成 MoviePilot 反馈 Issue 预览")
parser.add_argument("--draft-file", required=True, help="包含 Issue 草稿字段的 JSON 文件")
return parser.parse_args()
def main() -> int:
"""脚本入口:校验草稿并输出 JSON 结果。"""
args = parse_args()
result = prepare_issue(args.draft_file)
print(result_payload(**result))
return 0 if result.get("success") else 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,263 @@
"""提交 feedback-issue payload 到 MoviePilot 上游 GitHub 仓库。"""
from __future__ import annotations
import argparse
from pathlib import Path
from typing import Any, Optional
from feedback_issue_common import (
ALLOWED_ENVIRONMENTS,
ALLOWED_ISSUE_TYPES,
FEEDBACK_ISSUE_API,
FEEDBACK_REPO,
FEEDBACK_REQUEST_TIMEOUT,
MAX_TITLE_CHARS,
build_issue_body,
build_prefill_url,
check_content_quality,
check_recent_duplicate,
check_user_rate_limit,
classify_failure,
load_diagnostics_logs,
load_submission_state,
read_json_file,
record_submission,
record_user_submission,
result_payload,
safe_response_dict,
save_submission_state,
settings,
truncate,
validate_enum,
)
from app.utils.http import RequestUtils
REQUIRED_PAYLOAD_FIELDS = (
"title",
"version",
"environment",
"issue_type",
"description",
"original_user_request",
"diagnostics_file",
)
def normalize_payload(raw: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
"""规范化提交 payload 并返回缺失字段。"""
payload = {key: str(raw.get(key) or "").strip() for key in REQUIRED_PAYLOAD_FIELDS}
missing = [key for key, value in payload.items() if not value]
payload["title"] = truncate(payload["title"], MAX_TITLE_CHARS, marker="...")
return payload, missing
def validate_payload(payload: dict[str, Any], logs: str) -> Optional[str]:
"""校验提交 payload 的枚举值和内容质量。"""
for value, allowed, field_name in (
(payload["environment"], ALLOWED_ENVIRONMENTS, "environment"),
(payload["issue_type"], ALLOWED_ISSUE_TYPES, "issue_type"),
):
error = validate_enum(value, allowed, field_name)
if error:
return error
return check_content_quality(
title=payload["title"],
description=payload["description"],
original_user_request=payload["original_user_request"],
logs=logs,
)
def build_no_token_result(payload: dict[str, Any], logs: str) -> dict[str, Any]:
"""构造未配置 GitHub Token 时的预填链接降级结果。"""
prefill_url = build_prefill_url(
title=payload["title"],
version=payload["version"],
environment=payload["environment"],
issue_type=payload["issue_type"],
description=payload["description"],
logs=logs,
)
return {
"success": False,
"reason": "no_token",
"repo": FEEDBACK_REPO,
"prefill_url": prefill_url,
"message": (
"MoviePilot 未配置可写入的 GitHub Token无法自动提交 Issue。"
"请把 prefill_url 原样发给用户,由用户在浏览器或 GitHub App 中确认提交。"
),
}
def post_github_issue(payload: dict[str, Any], body: str) -> Any:
"""调用 GitHub REST API 创建 Issue 并返回响应对象。"""
request_headers = {
**settings.GITHUB_HEADERS,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
}
request_payload = {
"title": payload["title"],
"body": body,
"labels": ["bug"],
}
return RequestUtils(
proxies=settings.PROXY,
headers=request_headers,
timeout=FEEDBACK_REQUEST_TIMEOUT,
).post(FEEDBACK_ISSUE_API, json=request_payload)
def build_api_failure_result(
*,
reason: str,
payload: dict[str, Any],
logs: str,
github_message: str | None = None,
) -> dict[str, Any]:
"""构造 GitHub API 失败后的预填链接兜底结果。"""
prefill_url = build_prefill_url(
title=payload["title"],
version=payload["version"],
environment=payload["environment"],
issue_type=payload["issue_type"],
description=payload["description"],
logs=logs,
)
return {
"success": False,
"reason": reason,
"repo": FEEDBACK_REPO,
"prefill_url": prefill_url,
"github_message": github_message,
"message": "GitHub API 未能自动创建 Issue请把 prefill_url 原样发给用户手动提交。",
}
def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
"""读取 payload 文件并执行提交或预填链接降级流程。"""
raw = read_json_file(payload_file)
payload, missing = normalize_payload(raw)
if missing:
return {
"success": False,
"reason": "missing_fields",
"message": f"payload 缺少必填字段:{', '.join(missing)}",
}
try:
logs, _ = load_diagnostics_logs(payload["diagnostics_file"])
except Exception as err:
return {
"success": False,
"reason": "diagnostics_missing",
"message": f"无法读取诊断日志文件:{err}",
}
error = validate_payload(payload, logs)
if error:
return {
"success": False,
"reason": "rejected_quality",
"message": error,
}
body = build_issue_body(
version=payload["version"],
environment=payload["environment"],
issue_type=payload["issue_type"],
description=payload["description"],
logs=logs,
)
state = load_submission_state()
if check_recent_duplicate(payload["title"], body, state):
return {
"success": False,
"reason": "duplicate",
"message": "该问题反馈在 60 秒内已经提交或尝试提交过一次,已避免重复提交。",
}
rate_error = check_user_rate_limit(username, state)
if rate_error:
result = build_api_failure_result(
reason="rate_limited_user",
payload=payload,
logs=logs,
)
result["message"] = rate_error + " 如确实是另一个真实问题,请使用 prefill_url 手动提交。"
save_submission_state(state)
return result
record_user_submission(username, state)
if not settings.GITHUB_TOKEN:
save_submission_state(state)
return build_no_token_result(payload, logs)
record_submission(payload["title"], body, state)
save_submission_state(state)
try:
response = post_github_issue(payload, body)
except Exception as err:
return build_api_failure_result(
reason="network_error",
payload=payload,
logs=logs,
github_message=str(err),
)
if response is None:
return build_api_failure_result(
reason="network_error",
payload=payload,
logs=logs,
)
if response.status_code == 201:
data = safe_response_dict(response)
return {
"success": True,
"repo": FEEDBACK_REPO,
"issue_number": data.get("number"),
"issue_url": data.get("html_url"),
"message": "Issue 已成功提交到 MoviePilot 上游仓库。",
}
reason = classify_failure(response.status_code, headers=dict(response.headers or {}))
api_data = safe_response_dict(response)
api_message = api_data.get("message") if api_data else None
if not api_message and getattr(response, "text", None):
api_message = response.text[:200]
return build_api_failure_result(
reason=reason,
payload=payload,
logs=logs,
github_message=api_message,
)
def parse_args() -> argparse.Namespace:
"""解析命令行参数。"""
parser = argparse.ArgumentParser(description="提交 MoviePilot 反馈 Issue")
parser.add_argument("--payload-file", required=True, help="prepare 脚本生成的 payload JSON 文件")
parser.add_argument(
"--username",
default="agent-admin",
help="用于提交频率限制的管理员用户名;未知时保留默认值",
)
return parser.parse_args()
def main() -> int:
"""脚本入口:输出 JSON 提交结果。"""
args = parse_args()
result = submit_issue(args.payload_file, args.username)
print(result_payload(**result))
return 0 if result.get("success") or result.get("reason") in {"no_token"} else 2
if __name__ == "__main__":
raise SystemExit(main())