157 lines
5.0 KiB
Python
157 lines
5.0 KiB
Python
"""
|
|
后台机器人回复语:按 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 []
|