""" 后台机器人回复语:按 reply_code 取正文。 本文件职责:短缓存主账已启用列表,把 {{ticket_no}} 等变量填进模板。 禁止:当六态;禁止回调线程同步等完整 LLM;失败必须回退 inquiry_copy 默认句。 线程:进程内锁刷新;不同工单可读同一份缓存。 """ from __future__ import annotations import logging import os import re import threading import time from typing import Any, Optional logger = logging.getLogger(__name__) # 后台改文案后最多这么久出现在销售侧;不当 checkpoint。 _TTL_SECONDS = 60.0 _PLACEHOLDER = re.compile(r"\{\{\s*([a-zA-Z0-9_]+)\s*\}\}") _lock = threading.Lock() _loaded_at = 0.0 _by_code: dict[str, str] = {} # 单测注入;None 表示走主账/空缓存。 _override: Optional[dict[str, str]] = None def override_catalog(mapping: Optional[dict[str, str]]) -> None: """ 单测注入调用编码 → 正文。传 None 清掉注入。 副作用:清空加载时间,下次 render 重新取。不打主账。 """ global _override, _loaded_at, _by_code with _lock: _override = dict(mapping) if mapping is not None else None _loaded_at = 0.0 if mapping is None: _by_code = {} def fill(template: str, values: dict[str, Any] | None = None) -> str: """把 {{ticket_no}} 换成实值;未提供的占位原样保留。""" src = dict(values or {}) def _repl(match: re.Match[str]) -> str: key = match.group(1) if key not in src: return match.group(0) raw = src.get(key) if raw is None: return "" return str(raw) return _PLACEHOLDER.sub(_repl, template or "") def render(code: str, default: str, **kwargs: Any) -> str: """ 取已启用回复语并填变量。 停用、库空、主账失败、编码对不上:返回 default(已是拼好的默认句)。 副作用:缓存过期时 GET 主账;超时/失败吞掉,不抛给销售。 """ key = (code or "").strip() fallback = default if default is not None else "" if not key: return fallback tpl = _lookup(key) if not (tpl or "").strip(): return fallback return fill(tpl, kwargs) def _lookup(code: str) -> str: """读缓存;过期再拉主账。override 优先。""" global _loaded_at, _by_code with _lock: if _override is not None: return str(_override.get(code) or "") now = time.monotonic() if _loaded_at > 0 and (now - _loaded_at) < _TTL_SECONDS: return str(_by_code.get(code) or "") fresh = _fetch() with _lock: if _override is not None: return str(_override.get(code) or "") if fresh is not None: _by_code = fresh _loaded_at = time.monotonic() elif not _by_code: _loaded_at = time.monotonic() return str(_by_code.get(code) or "") def _fetch() -> Optional[dict[str, str]]: """ GET /inquiry/agent/botReply/listEnabled。 未配主账地址视为空,返回 {{}} 以免每次空打。失败返回 None 以保留旧缓存。 """ try: from agent.config import get_settings cfg = get_settings() backend = str( os.environ.get("LEDGER_BACKEND") or getattr(cfg, "ledger_backend", "") or "" ).strip().lower() if backend in {"memory", "mem", "stub"}: return {} if not str(getattr(cfg, "biz_api_base_url", "") or "").strip(): return {} from agent.ledger import LedgerClient from agent.ledger.api_shell import LedgerApiShell client = LedgerClient.from_settings() client.timeout_seconds = min(float(client.timeout_seconds or 10), 3.0) resp = LedgerApiShell(client=client).list_bot_replies() if not resp.ok: logger.warning("bot_reply listEnabled fail status=%s err=%s", resp.status_code, resp.error) return None rows = _unwrap_rows(resp.data) out: dict[str, str] = {} for row in rows: if not isinstance(row, dict): continue code = str(row.get("replyCode") or row.get("reply_code") or "").strip() text = str(row.get("replyText") or row.get("reply_text") or "") if code and text.strip(): out[code] = text return out except Exception: logger.warning("bot_reply listEnabled 异常,回退默认句", exc_info=True) return None def _unwrap_rows(data: Any) -> list[Any]: """解开 Jeecg Result 列表。""" if isinstance(data, list): return data if not isinstance(data, dict): return [] inner = data.get("result") if isinstance(inner, list): return inner if isinstance(inner, dict): rec = inner.get("records") if isinstance(rec, list): return rec rec = data.get("records") or data.get("data") if isinstance(rec, list): return rec return []