Files
inquiry_robot/inquiry-agent/agent/policy/bot_reply.py
T

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 []