deid.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. # -*- coding: utf-8 -*-
  2. """外发脱敏 — 送到第三方模型之前的实体去标识。
  3. 与 scripts/windscada_cloud_pack.py 的分工:
  4. cloud_pack 管**产物**(HTML 源码级, 含文档名正则等 HTML 特有规则), 是上云前的一次性闸;
  5. 本模块 管**外发**(每次调用在线模型时逐条消息脱敏), 是运行期持续生效的闸。
  6. 两者共用下面这张实体表 —— 实体只在这里定义一次, 免得两处规则各自漂移。
  7. 为什么外发也必须脱: 演示时客户每问一次, 证据包就整包发给第三方模型一次。
  8. 产物脱敏管不到这条路径 —— 那是两个不同的出口。
  9. """
  10. from app_common.app_common_guanlan.api import paths as P
  11. import re
  12. # 实体映射: 长串在前 (中广核如东 → 先替场名会留下 "中广核the site")
  13. ENTITIES = [
  14. ('中广核', '[owner]'), ('华能', '[owner]'), ('国家能源', '[owner]'),
  15. ('国电投', '[owner]'), ('大唐', '[owner]'), ('华电', '[owner]'), ('三峡', '[owner]'),
  16. ('上海电气', 'OEM'), ('西门子', 'OEM'), ('Siemens', 'OEM'),
  17. ('Envision', 'supplier'), ('远景', 'supplier'), ('ABB', 'supplier'),
  18. ('如东海上', 'the offshore site'), ('如东', 'the site'),
  19. ('rudong', 'site'), ('RUDONG', 'SITE'), ('Rudong', 'Site'),
  20. ('SWT-4.0-130', '4.0 MW class'), ('SWT130-4000', '4.0 MW class'),
  21. ]
  22. _RE_PATH = re.compile(r'/(?:Users|Volumes)/[^\s;,)"\']+')
  23. _RE_TARIFF = re.compile(r'[\d.]+\s*(?:~\s*[\d.]+\s*)?元/kWh')
  24. # 显式台数: 2026-08-31 双模审第 5 条逮到 —— 我写了外发脱敏却没拿产物侧那 14 项闸验过它,
  25. # 实测证据包里 "38 台" 原样外发。写了代码 ≠ 验过, 闸必须对**两个出口**都跑。
  26. _RE_FLEET = re.compile(r'(?<![\d.])38\s*(?:台|units|turbines)')
  27. def scrub(t):
  28. """对一段文本做外发脱敏。非字符串原样返回。"""
  29. if not isinstance(t, str) or not t:
  30. return t
  31. t = _RE_PATH.sub('[local path]', t)
  32. t = _RE_TARIFF.sub('[reference tariff]', t)
  33. t = _RE_FLEET.sub('the fleet', t)
  34. for k, v in ENTITIES:
  35. t = t.replace(k, v)
  36. return t
  37. def scrub_messages(msgs):
  38. """对 chat messages 逐条脱敏; 只动 content, 不碰 role / tool_calls 结构。
  39. tool_calls 里的 arguments 不脱: 那是**发给本地工具**的检索参数 (台号要原样才查得到),
  40. 并不会离开本机 —— 离开本机的是 content。混着脱会让检索查不到东西。
  41. """
  42. out = []
  43. for m in msgs:
  44. if isinstance(m, dict) and isinstance(m.get('content'), str):
  45. m = dict(m, content=scrub(m['content']))
  46. out.append(m)
  47. return out
  48. # ── 机组代号 ───────────────────────────────────────────────────────
  49. # 与 scripts/windscada_cloud_pack.py 的 _unit_map 必须**同算法同结果**: 页面上显示 A56,
  50. # 问答答案里也必须是 A56, 否则客户对不上号。故算法写在这里单源, 两边都调。
  51. # md5 定序保证确定性 (跨进程、跨次出包一致); 撞号沿数字轴平移, 绝不把两台并成一个。
  52. def unit_map(ids):
  53. import hashlib
  54. m, used = {}, set()
  55. for i in sorted(set(ids)):
  56. h = hashlib.md5(('guanlan-unit-' + i).encode()).hexdigest()
  57. a, n = chr(65 + int(h[:2], 16) % 26), int(h[2:6], 16) % 90 + 10
  58. while a + str(n) in used:
  59. n = (n - 9) % 90 + 10
  60. used.add(a + str(n))
  61. m[i] = a + str(n)
  62. if len(set(m.values())) != len(m):
  63. raise ValueError(f'代号冲突: {len(m)} 台只生成 {len(set(m.values()))} 个')
  64. return m
  65. _UMAP = None
  66. def unitize_answer(t, ids=None):
  67. """把答案里的 WTGnn / nn# 换成代号。仅用于**返回给前端**, 不可用于发给模型的消息 ——
  68. 模型要靠原始台号调检索工具, 换了就查不到。"""
  69. global _UMAP
  70. if not isinstance(t, str) or not t:
  71. return t
  72. if _UMAP is None:
  73. if ids is None:
  74. import json as _j, pathlib as _p, re as _r
  75. p = P.objects_json()
  76. ids = sorted(set(_r.findall(r'WTG(\d{2})', p.read_text(encoding='utf-8')))) if p.exists() else []
  77. _UMAP = unit_map(ids) if ids else {}
  78. import re as _re
  79. for i, c in _UMAP.items():
  80. t = t.replace('WTG' + i, c)
  81. t = _re.sub(r'(?<!\d)' + i + r'#', c + '#', t)
  82. return t
  83. def ununitize_question(t, ids=None):
  84. """把公网代号问题反解回 WTGnn, 仅供服务端检索使用; 不回显给前端。"""
  85. global _UMAP
  86. if not isinstance(t, str) or not t:
  87. return t
  88. if _UMAP is None:
  89. if ids is None:
  90. import pathlib as _p, re as _r
  91. p = P.objects_json()
  92. ids = sorted(set(_r.findall(r'WTG(\d{2})', p.read_text(encoding='utf-8')))) if p.exists() else []
  93. _UMAP = unit_map(ids) if ids else {}
  94. inv = {v: 'WTG' + k for k, v in _UMAP.items()}
  95. import re as _re
  96. for alias, raw in sorted(inv.items(), key=lambda kv: -len(kv[0])):
  97. t = _re.sub(r'(?<![A-Za-z0-9])' + _re.escape(alias) + r'(?![A-Za-z0-9])', raw, t)
  98. return t