File size: 21,337 Bytes
241a123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8d002eb
 
 
 
241a123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8d002eb
241a123
 
 
 
 
 
 
 
 
 
 
8d002eb
 
 
 
 
 
 
 
 
 
 
 
241a123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
"""把上游/后端的技术报错翻译成普通用户看得懂的大白话。

设计要点:
- 规则由管理员在后台配置,形如「关键词列表 -> 展示文案」;命中任意关键词即替换。
- **只影响前台展示**:后台日志(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()