Download services/error_message_service.py from zlcy/study: direct link, hf CLI and curl.
- Browser
- Download file 21.3 kB
-
https://huggingface.co/spaces/zlcy/study/resolve/main/services/error_message_service.py
- Command line
-
hf download hf://spaces/zlcy/study/services/error_message_service.py
-
curl -L -o error_message_service.py https://huggingface.co/spaces/zlcy/study/resolve/main/services/error_message_service.py
21.3 kB
| """把上游/后端的技术报错翻译成普通用户看得懂的大白话。 | |
| 设计要点: | |
| - 规则由管理员在后台配置,形如「关键词列表 -> 展示文案」;命中任意关键词即替换。 | |
| - **只影响前台展示**:后台日志(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 | |
| 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) | |
| 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() | |