assistant_llm.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. # -*- coding: utf-8 -*-
  2. """assistant_llm.py — 柱3 助手 LLM 语言皮层 (叙事层; 判断留结构化, 反幻觉接地闸).
  3. ★edge三连证铁律: **LLM 只做语言皮层(叙事/措辞), 判断在结构化层(assistant.answer)**。
  4. 本层 = 把 assistant.answer 的 grounded dict 用自然语言复述给现场工程师, 但:
  5. - **反幻觉接地闸**: LLM 叙事若引入结构化答案里没有的**台号/case_id**(高危捏造) → 拒绝, 回退结构化。
  6. - **优雅回退**: 无 llm_fn / LLM 失败 / 接地闸不过 → 返回确定性结构化答案 (LLM 纯附加, 不可破)。
  7. - **INSUFFICIENT 透传**: 结构化判 INSUFFICIENT → 叙事必说"判不了", LLM 不许"圆"成有结论。
  8. = 即便加 LLM, "可审计 + 知道自己不知道" thesis 不破 (结构化答案永远是权威版, 随 nl 一起返回可核)。
  9. llm_fn(prompt)->str|None 可插拔 (ollama_llm_fn 尽力+失败None; 也可接任意 provider)。
  10. """
  11. try:
  12. from app_common.app_common_guanlan.api import install_root as _install_root
  13. except ImportError: # 理论不可达;包结构异常时回退到按位置上跳
  14. from pathlib import Path as _P
  15. def _install_root(_f): return _P(_f).resolve().parents[4]
  16. from src import paths as P
  17. import re
  18. from src.sop.assistant import answer as _structured_answer
  19. # 台号 token (高危捏造): A/B/D/W/F + 1-3位数字, 或 'N号风机'
  20. _TID_RE = re.compile(r'[ABDWF]\d{1,3}|\d{1,3}号风机')
  21. def _tid_tokens(text):
  22. return set(_TID_RE.findall(str(text)))
  23. def _grounding_ok(nl, structured):
  24. """接地闸: nl 叙事的台号 token 必 ⊆ 结构化答案(answer+grounded_on)的台号 token。
  25. 引入新台号 = 捏造 → False。数字/措辞不检(低危, LLM 复述会动)。"""
  26. allowed = _tid_tokens(structured.get('answer', '')) | _tid_tokens(' '.join(map(str, structured.get('grounded_on', []))))
  27. return _tid_tokens(nl) <= allowed
  28. def _prompt(structured, query, farm):
  29. return (f"你是风电运维助手, 回答现场工程师。用户问: {query}\n\n"
  30. f"下面是结构化诊断结果 = **唯一事实来源, 不许增删任何事实/台号/数字/结论**:\n"
  31. f" 判级(verdict): {structured.get('verdict')}\n"
  32. f" 内容: {structured.get('answer')}\n"
  33. f" 出处(case_id/finding): {structured.get('grounded_on')}\n\n"
  34. f"要求: ①只用上面的事实复述, 不编造任何台号/数字/机制 ②带出处 "
  35. f"③若 verdict=INSUFFICIENT 必须明说'判不了/数据不支持', 不许圆成有结论 "
  36. f"④简洁, 给现场能用的话。直接输出复述, 不要前言。")
  37. def narrate(structured, query, llm_fn=None, farm='?'):
  38. """结构化答案 → 自然语言叙事 (LLM 皮层 + 接地闸 + 回退)。
  39. 返回 {nl, source, grounding_ok, verdict, grounded_on, structured}。
  40. source ∈ {structured(无LLM/失败), llm(过闸), llm_blocked(接地闸拦截,回退结构化)}。"""
  41. base = {'verdict': structured.get('verdict'), 'grounded_on': structured.get('grounded_on', []),
  42. 'structured': structured.get('answer', '')}
  43. if llm_fn is None:
  44. return {**base, 'nl': structured.get('answer', ''), 'source': 'structured', 'grounding_ok': True}
  45. try:
  46. nl = llm_fn(_prompt(structured, query, farm))
  47. except Exception:
  48. nl = None
  49. if not nl or not str(nl).strip():
  50. return {**base, 'nl': structured.get('answer', ''), 'source': 'structured', 'grounding_ok': True}
  51. if not _grounding_ok(str(nl), structured):
  52. # 接地闸拦截: LLM 引入了结构化里没有的台号 → 判定捏造, 回退权威结构化版
  53. return {**base, 'nl': structured.get('answer', ''), 'source': 'llm_blocked', 'grounding_ok': False}
  54. return {**base, 'nl': str(nl).strip(), 'source': 'llm', 'grounding_ok': True}
  55. def answer_nl(query, cases, findings=None, farm='?', llm_fn=None):
  56. """柱3 一站: 结构化判断(assistant.answer) + LLM 叙事皮层。判断永远来自结构化层。"""
  57. structured = _structured_answer(query, cases, findings, farm)
  58. return {**narrate(structured, query, llm_fn, farm), 'intent': structured.get('intent')}
  59. def ollama_llm_fn(model='qwen2.5:7b', url='http://localhost:11434/api/generate', timeout=30):
  60. """Ollama 适配 (尽力; 未运行/超时/任何错 → 返回 None 触发回退)。返回 llm_fn 闭包。"""
  61. import json
  62. import urllib.request
  63. def _fn(prompt):
  64. try:
  65. req = urllib.request.Request(
  66. url, data=json.dumps({'model': model, 'prompt': prompt, 'stream': False}).encode('utf-8'),
  67. headers={'Content-Type': 'application/json'})
  68. with urllib.request.urlopen(req, timeout=timeout) as r:
  69. return json.loads(r.read().decode('utf-8')).get('response')
  70. except Exception:
  71. return None
  72. return _fn
  73. # ---------------------------------------------------------------------------
  74. if __name__ == '__main__':
  75. # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
  76. # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
  77. # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
  78. import sys as _sys
  79. for _s in (_sys.stdout, _sys.stderr):
  80. try: _s.reconfigure(errors='replace')
  81. except Exception: pass
  82. # CLI: python -m src.sop.assistant_llm <farm> "问题" (有Ollama则LLM叙事, 否则回退结构化)
  83. import argparse
  84. import json
  85. from pathlib import Path
  86. ROOT = _install_root(__file__)
  87. ap = argparse.ArgumentParser()
  88. ap.add_argument('farm')
  89. ap.add_argument('query')
  90. ap.add_argument('--no-llm', action='store_true', help='强制结构化(不调LLM)')
  91. a = ap.parse_args()
  92. sopd = P.sop(a.farm)
  93. cases = json.loads((sopd / 'cases.json').read_text(encoding='utf-8')) if (sopd / 'cases.json').exists() else []
  94. findings = (json.loads((sopd / 'findings.json').read_text(encoding='utf-8')).get('findings')
  95. if (sopd / 'findings.json').exists() else [])
  96. llm = None if a.no_llm else ollama_llm_fn()
  97. r = answer_nl(a.query, cases, findings, farm=a.farm, llm_fn=llm)
  98. print(f"[{r['intent']}/{r['verdict']}/source={r['source']}]")
  99. print(r['nl'])
  100. print(f"\n出处: {r['grounded_on'][:5]}")
  101. if r['source'] == 'llm_blocked':
  102. print("⚠ LLM 叙事引入结构化外台号(疑捏造) → 已拦截回退结构化权威版")