"""把上游/后端的技术报错翻译成普通用户看得懂的大白话。 设计要点: - 规则由管理员在后台配置,形如「关键词列表 -> 展示文案」;命中任意关键词即替换。 - **只影响前台展示**:后台日志(LogService)始终保留原始报错,方便排查。 - 未命中任何规则时保留原始报错,同时把这条「未配置报错」记下来, 后台会看到一个待办列表,照着补规则即可 —— 规则库能自我完善。 """ from __future__ import annotations import json import re import threading import time from pathlib import Path from typing import Any from services.config import DATA_DIR # 未配置报错的收集文件。与业务数据放在一起,随持久卷保留。 UNCONFIGURED_ERRORS_FILE = DATA_DIR / "error-message-unconfigured.json" # 同一个报错模板最多保留多少条样本;超出后只累加计数,避免文件无限膨胀。 _MAX_UNCONFIGURED_ENTRIES = 200 # 单条样本的原文上限,防止把整段 HTML/JSON 塞进去。 _MAX_SAMPLE_CHARS = 600 # 关键词的最短长度:太短的关键词(如 "a")会误伤一大片正常报错。 _MIN_KEYWORD_CHARS = 2 # 归一化后用于去重的指纹长度。 _FINGERPRINT_CHARS = 16 # 未命中任何规则、且管理员打开了「未匹配时报错也兜底成通用文案」时,前台显示这句话。 # ⚠️ 只影响前台文案:原始报错照样进「未配置报错」台账,规则库仍能自我完善。 FALLBACK_DISPLAY = "操作失败,请稍后重试" class ErrorMessageRuleError(ValueError): """规则校验失败。""" def _clean(value: object) -> str: return str(value or "").strip() def _normalize_keywords(value: object) -> list[str]: """把关键词字段归一化成去重、非空、长度达标的列表。 支持列表或换行/逗号/中文顿号分隔的字符串,方便后台直接粘贴多行。 """ raw: list[str] = [] if isinstance(value, (list, tuple)): raw = [str(item or "") for item in value] elif isinstance(value, str): text = value.replace(",", "\n").replace(",", "\n").replace("、", "\n") raw = text.splitlines() result: list[str] = [] seen: set[str] = set() for item in raw: keyword = item.strip() if len(keyword) < _MIN_KEYWORD_CHARS: continue lowered = keyword.lower() if lowered in seen: continue seen.add(lowered) result.append(keyword) return result def normalize_rule(value: object, *, fallback_index: int = 0) -> dict[str, Any] | None: """归一化单条规则;缺少关键词或文案则返回 None(视为无效,直接丢弃)。""" if not isinstance(value, dict): return None rule_id = _clean(value.get("id")) or f"rule-{fallback_index + 1}" keywords = _normalize_keywords(value.get("keywords")) display = _clean(value.get("display")) if not keywords or not display: return None enabled = value.get("enabled") if isinstance(enabled, str): enabled = enabled.strip().lower() in {"1", "true", "yes", "on"} elif enabled is None: enabled = True else: enabled = bool(enabled) return { "id": rule_id, "keywords": keywords, "display": display, "enabled": enabled, "note": _clean(value.get("note")), } def normalize_rules(value: object) -> list[dict[str, Any]]: """归一化规则列表,丢弃无效项并保证 id 唯一。""" if not isinstance(value, (list, tuple)): return [] rules: list[dict[str, Any]] = [] seen_ids: set[str] = set() for index, item in enumerate(value): rule = normalize_rule(item, fallback_index=index) if rule is None: continue base_id = rule["id"] suffix = 1 while rule["id"] in seen_ids: suffix += 1 rule["id"] = f"{base_id}-{suffix}" seen_ids.add(rule["id"]) rules.append(rule) return rules # --------------------------------------------------------------- AI 归纳规则 # 「AI 归纳」的 system prompt:让模型只回 JSON,并禁止它编造原文里没有的词。 ERROR_SUMMARY_SYSTEM_PROMPT = ( "你是这个上游中转服务的运维助手,负责把一条报错归纳成「报错文案映射规则」。\n" "用户会给你一段报错原文,请输出两部分:\n" "1. keywords:2-4 个能**唯一标识这一类**报错的关键词," "必须是原文里**原样出现**的子串,不要翻译、不要改写、不要造词。\n" "【最关键的一条】本服务的匹配是「原文包含**任意一个**关键词即命中」," "不是全部命中 —— 关键词越多,这条规则覆盖的报错就越宽。" "所以宁可少而准,每个关键词都要单凭它自己就能定位到这一类报错。\n" "本服务的报错格式固定为 `{接口路径} failed: status={状态码}, body={上游返回体}`。" "接口路径是上游接口,同一个前缀会对应多种完全不同的操作" "(例如 /backend-api/files 下面既有创建、上传确认,也有下载和文件库)," "所以取路径时要**带上能区分「哪一步」的片段**(如 /uploaded、/download、/library、/conversation)," "只写 /backend-api/files 这样的前缀会误伤一大片其它报错。\n" "优先选:错误标识符(如 upload_protection_limit_exceeded)、" "「步骤 + 状态码」组合(如 /uploaded failed: status=500)、" "状态码(如 status=429)、curl 错误号(如 curl: (16))。\n" "严禁选过于通用、会误伤正常报错的词(如 error、failed、exception、status、body、detail、" "只出现一次的 file_id / uuid)。\n" "2. display:一句给普通用户看的中文说明,不超过 20 个字。" "不要出现英文术语、状态码、接口路径、账号邮箱、网址或联系方式。\n" "只返回 JSON,不要解释、不要 markdown 代码块,格式固定为:" '{"keywords": ["..."], "display": "..."}' ) # 抽取候选关键词时要忽略的「太通用」片段:拿它们当规则会误伤一片正常报错。 # 其中一批是 JSON 字段名(error_code、reason…)——它们是报错的「骨架」, # 几乎每条报错都有,当关键词等于没筛。 _GENERIC_TOKENS = frozenset({ "error", "errors", "failed", "failure", "exception", "status", "body", "detail", "details", "message", "msg", "type", "code", "request", "response", "http", "https", "true", "false", "null", "none", "data", "error_code", "error_message", "error_type", "error_detail", }) # 每次请求都会变的高熵片段(uuid / 上游 file_id / 长十六进制)。 # 把它们当关键词等于给单次请求打标,下一次必然命中不了。 _VOLATILE_SEGMENT = re.compile( r"^(?:[0-9a-f]{8,}|file_[0-9a-z]{6,}|[0-9]{6,})$", re.IGNORECASE, ) def _strip_volatile_path(path: str) -> str: """把路径截断到第一个高熵片段之前。 `/backend-api/files/file_00000000ab.../uploaded` → `/backend-api/files`: 前两段才代表链路,后面是单次请求的 id。 """ kept: list[str] = [] for segment in str(path or "").split("/"): if segment and _VOLATILE_SEGMENT.match(segment): break kept.append(segment) trimmed = "/".join(kept).rstrip("/") # 只留单段的路径太泛(/files 之类),不值得当关键词。 return trimmed if trimmed.count("/") >= 2 else "" # 形如 `"reason": "xxx"` 的 JSON 键值片段。 _JSON_FRAGMENT = re.compile(r'"\s*:\s*"') def _sanitize_keyword(value: object) -> str: """剥掉关键词两端的 JSON 语法碎片,只留下「值」本身。 归纳(模型或本地抽取)偶尔会把 `reason": "upload_protection_limit_exceeded` 这种从报文里直接截出来的片段整条当关键词。它虽然能把当前这条报错匹配上, 但可读性差,而且上游 JSON 的字段顺序/空格一变就失效 —— 例如相邻字段换成 `"a": "reason": "upload_...` 时,`reason` 就跑到中间、不再是行首了。 ⚠️ 只用于「归纳」链路;后台手输的关键词走 _normalize_keywords 原样保留, 避免把管理员明确想匹配的带引号子串改掉。 """ text = str(value or "").strip() if not text: return "" parts = _JSON_FRAGMENT.split(text) if len(parts) > 1: text = parts[-1] return text.strip("\"':,{}[]\\").strip() def _dedupe_ordered(items: list[str]) -> list[str]: """按首次出现顺序去重,同时过滤空值和过短的项。""" result: list[str] = [] seen: set[str] = set() for item in items: text = str(item or "").strip() if len(text) < _MIN_KEYWORD_CHARS: continue lowered = text.lower() if lowered in seen: continue seen.add(lowered) result.append(text) return result def extract_keyword_candidates(message: object) -> list[str]: """从报错原文里抽出可直接用作关键词的片段(不依赖模型)。 这是「AI 归纳」的兜底:模型不可用、超时或被限流时,仍然能给出一组 高辨识度的候选词,管理员补一句文案即可。抽取顺序即优先级,越靠前 越能唯一标识这类报错。 """ import re text = str(message or "") if not text.strip(): return [] candidates: list[str] = [] # 1) 结构化字段值:reason / error_code / code —— 这些是上游给的精确标识 for pattern in ( r'"reason"\s*:\s*"([^"]+)"', r'"error_code"\s*:\s*"([^"]+)"', r'"code"\s*:\s*"([^"]+)"', ): candidates.extend(re.findall(pattern, text, flags=re.IGNORECASE)) # 2) 接口路径:/backend-api/xxx 这类前缀最能区分链路。 # 路径里常混着每次请求都变的 file_id / uuid,必须截断, # 否则规则只对这一次报错生效,下次换 id 就再也命中不了。 candidates.extend( trimmed for trimmed in ( _strip_volatile_path(item) for item in re.findall(r"/[a-z0-9][a-z0-9._/-]{3,}", text, flags=re.IGNORECASE) ) if trimmed ) # 3) 状态码:status=429 candidates.extend(re.findall(r"status=\d{3}", text, flags=re.IGNORECASE)) # 4) curl 错误号:curl: (16) candidates.extend(re.findall(r"curl:\s*\(\d+\)", text, flags=re.IGNORECASE)) # 5) 长标识符(snake_case / 驼峰之外的连续字母数字串);高熵的不收。 candidates.extend( item for item in re.findall(r"[a-z][a-z0-9_]{7,}", text, flags=re.IGNORECASE) if not _VOLATILE_SEGMENT.match(item) ) # 路径会带出 /backend-api、/files 之类碎片,去掉只有斜杠的单段噪声 cleaned: list[str] = [] for item in candidates: fixed = _sanitize_keyword(item) if len(fixed) < _MIN_KEYWORD_CHARS or fixed == "/": continue if fixed.lower() in _GENERIC_TOKENS: continue cleaned.append(fixed) return _dedupe_ordered(cleaned)[:12] def parse_ai_suggestion(value: object, *, raw: str = "") -> dict[str, Any]: """把模型返回的文本解析成 {keywords, display}。 - 容忍 markdown 代码块、前后解释文字(取第一个 JSON 对象)。 - **只保留在原文里真实出现的关键词**:匹配是子串匹配,编造的词永远命中不了。 """ import json import re text = str(value or "").strip() if not text: return {"keywords": [], "display": ""} if text.startswith("```"): text = re.sub(r"^```[a-zA-Z]*\s*", "", text) text = re.sub(r"\s*```$", "", text).strip() match = re.search(r"\{.*\}", text, flags=re.DOTALL) if match: text = match.group(0) try: data = json.loads(text) except ValueError: return {"keywords": [], "display": ""} if not isinstance(data, dict): return {"keywords": [], "display": ""} # 先剥 JSON 语法碎片(模型会把 `reason": "upload_xxx` 整条当关键词),再去重。 keywords = _normalize_keywords( [_sanitize_keyword(item) for item in _normalize_keywords(data.get("keywords"))] ) # 通用 JSON 字段名(error_code / reason / message…)几乎每条报错都有, # 当关键词等于把所有报错都命中,必须剔掉(本地抽取走的是同一份黑名单)。 keywords = [keyword for keyword in keywords if keyword.lower() not in _GENERIC_TOKENS] lowered = str(raw or "").lower() if lowered: keywords = [keyword for keyword in keywords if keyword.lower() in lowered] return {"keywords": keywords, "display": _clean(data.get("display"))} def _fingerprint(message: str) -> str: """把报错原文压成一个稳定指纹,用于同模板去重。 同一类报错每次的 uuid / 时间戳 / 文件名都不同,直接按原文去重会得到 成百上千条噪声;这里剥掉明显的可变片段(十六进制串、数字、引号内容) 后再算指纹。 """ import hashlib import re text = str(message or "") # UUID(8-4-4-4-12)整段归一,避免只吃掉前半段、剩余片段被 \d+ 打成 # 不同长度的 `#` 序列,导致同类报错算出不同指纹。 normalized = re.sub( r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", "#", text, flags=re.IGNORECASE, ) # 其余长十六进制串(hash / token / 文件名)同样归一。 normalized = re.sub(r"[0-9a-f]{8,}", "#", normalized, flags=re.IGNORECASE) normalized = re.sub(r"\d+", "#", normalized) normalized = re.sub(r"\s+", " ", normalized).strip().lower() return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:_FINGERPRINT_CHARS] class ErrorMessageService: def __init__(self, store_path: Path | None = None) -> None: self._path = store_path or UNCONFIGURED_ERRORS_FILE self._lock = threading.RLock() self._unconfigured_cache: dict[str, dict[str, Any]] | None = None # ---------------------------------------------------------------- 规则 def _rules(self) -> list[dict[str, Any]]: # 延迟导入:config 在导入期会读取 DATA_DIR,避免循环依赖。 from services.config import config return normalize_rules(config.data.get("error_message_rules")) def translate(self, message: object, *, channel: str = "") -> str: """把一条原始报错翻译成前台文案。 命中规则 -> 返回配置的大白话; 未命中 -> 开了「兜底」时返回通用文案,否则原样返回,并记录一条「未配置报错」待办。 """ text = _clean(message) if not text: return text lowered = text.lower() for rule in self._rules(): if not rule.get("enabled", True): continue if any(keyword.lower() in lowered for keyword in rule["keywords"]): return rule["display"] self._record_unconfigured(text, channel=channel) # 兜底只换前台文案;原文此时已记进「未配置报错」台账,管理员照旧能照着补规则。 return FALLBACK_DISPLAY if self.fallback_enabled() else text @staticmethod def fallback_enabled() -> bool: """「未匹配时报错也兜底成通用文案」开关(后台配置,默认关)。""" from services.config import config try: return bool(config.error_message_fallback_enabled) except Exception: # noqa: BLE001 - 配置读不到时按「不兜底」处理,别把原文吞掉 return False def translate_detail(self, detail: object, *, channel: str = "") -> object: """对 FastAPI 的 detail 结构做就地翻译,保持原有结构。""" if isinstance(detail, str): return self.translate(detail, channel=channel) if isinstance(detail, list): return [self.translate_detail(item, channel=channel) for item in detail] if isinstance(detail, dict): translated: dict[Any, Any] = {} for key, value in detail.items(): # 只翻译真正承载文案的字段,避免把 code / type 也换掉。 if key in {"message", "error", "detail", "msg"}: translated[key] = self.translate_detail(value, channel=channel) else: translated[key] = value return translated return detail # ------------------------------------------------------- 未配置报错收集 def _load_unconfigured(self) -> dict[str, dict[str, Any]]: with self._lock: if self._unconfigured_cache is not None: return self._unconfigured_cache data: dict[str, dict[str, Any]] = {} try: raw = json.loads(self._path.read_text(encoding="utf-8")) except (OSError, ValueError): raw = {} items = raw.get("items") if isinstance(raw, dict) else None if isinstance(items, list): for item in items: if not isinstance(item, dict): continue fingerprint = _clean(item.get("fingerprint")) if fingerprint: data[fingerprint] = item self._unconfigured_cache = data return data def _save_unconfigured(self, data: dict[str, dict[str, Any]]) -> None: with self._lock: payload = {"items": sorted(data.values(), key=lambda item: str(item.get("last_seen") or ""), reverse=True)} try: self._path.parent.mkdir(parents=True, exist_ok=True) temp = self._path.with_suffix(self._path.suffix + ".tmp") temp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") temp.replace(self._path) except OSError: # 收集失败绝不能影响正常请求链路。 return self._unconfigured_cache = data def _record_unconfigured(self, message: str, *, channel: str = "") -> None: fingerprint = _fingerprint(message) sample = message[:_MAX_SAMPLE_CHARS] with self._lock: data = self._load_unconfigured() existing = data.get(fingerprint) now = time.strftime("%Y-%m-%d %H:%M:%S") if existing is None and len(data) >= _MAX_UNCONFIGURED_ENTRIES: self._prune_locked(data) if existing is None: data[fingerprint] = { "fingerprint": fingerprint, "sample": sample, "count": 1, "channel": _clean(channel), "first_seen": now, "last_seen": now, } else: entry = dict(existing) entry["count"] = int(entry.get("count") or 0) + 1 entry["last_seen"] = now entry["sample"] = sample if channel and not entry.get("channel"): entry["channel"] = _clean(channel) data[fingerprint] = entry self._save_unconfigured(data) @staticmethod def _prune_locked(data: dict[str, dict[str, Any]]) -> None: """超出上限时先淘汰最久未出现、且出现次数最少的条目。""" ordered = sorted( data.values(), key=lambda item: (int(item.get("count") or 0), str(item.get("last_seen") or "")), ) for item in ordered[: max(1, len(ordered) // 10)]: data.pop(_clean(item.get("fingerprint")), None) def list_unconfigured(self) -> list[dict[str, Any]]: data = self._load_unconfigured() items = sorted( (dict(item) for item in data.values()), key=lambda item: ( -int(item.get("count") or 0), str(item.get("last_seen") or ""), ), ) # 顺带给出可直接用作关键词的候选片段,省得管理员从整段报错里手抠。 for item in items: item["keywords"] = extract_keyword_candidates(item.get("sample")) return items def clear_unconfigured(self, fingerprints: list[str] | None = None) -> int: with self._lock: data = self._load_unconfigured() if not fingerprints: removed = len(data) data.clear() else: removed = 0 for raw in fingerprints: fingerprint = _clean(raw) if fingerprint in data: data.pop(fingerprint) removed += 1 self._save_unconfigured(data) return removed error_message_service = ErrorMessageService()