feat(visitor): Implement visitor chat functionality and enhance customer service interactions

- Added a new visitor chat API endpoint (`/api/chat/visitor`) to allow unauthenticated users to engage in conversations without requiring customer data.
- Introduced a visitor context dependency to manage visitor interactions seamlessly.
- Enhanced the chat API to support explicit session termination and improved response handling for customer service interactions.
- Updated the database configuration to include Redis client support for caching visitor data.
- Added a new customer note repository to persist user notes independently of the L1 profile slots.

This update significantly improves the customer service experience by enabling visitor interactions and ensuring efficient data handling for both registered and unregistered users.
This commit is contained in:
2026-09-09 18:32:00 +08:00
parent ad930c26b1
commit b841f68295
98 changed files with 10682 additions and 630 deletions
+110
View File
@@ -0,0 +1,110 @@
"""合规护栏:违禁词扫描 + 风险提示 + 兜底话术。"""
from __future__ import annotations
import re
# ---------------------------------------------------------------------------
# 违禁词正则库
# ---------------------------------------------------------------------------
FORBIDDEN_TERMS: tuple[re.Pattern, ...] = (
# 建议买卖(允许中间 0~3 字,如「建议您买入」「建议客户加仓」)
re.compile(r"建议.{0,3}(买入|卖出|买|卖|持有|加仓|减仓|清仓)"),
re.compile(r"推荐.{0,3}(购买|买入|卖出|定投)"),
re.compile(r"(保证|承诺).{0,4}(收益|保本|盈利|赚钱)"),
re.compile(r"(一定|肯定|必定).{0,4}(涨|赚|盈利)"),
re.compile(r"稳赚|包赚|躺赚|零风险|无风险"),
re.compile(r"保本保息|刚性兑付"),
)
# ---------------------------------------------------------------------------
# 标准话术
# ---------------------------------------------------------------------------
RISK_DISCLAIMER = (
"【风险提示】以上信息仅供参考,不构成投资建议。"
"产品的风险等级、费用与赎回到账时间以官方法律文件为准,投资有风险,入市需谨慎。"
)
COMPLIANCE_REJECT = (
"抱歉,我无法提供投资建议。如需专业咨询,请转接人工客服或登录后联系理财顾问。"
)
# ---------------------------------------------------------------------------
# 分类拒绝话术(直接拒绝,不转人工)
# ---------------------------------------------------------------------------
REJECT_ADVICE = (
"抱歉,我无法提供投资建议或推荐具体产品。"
"如需专业投资咨询,建议您登录后联系专属理财顾问,或前往营业网点面询。"
)
REJECT_ACCOUNT = (
"抱歉,游客模式下无法查询个人账户信息。"
"请登录后查看您的持仓详情,如未注册可前往APP完成开户。"
)
REJECT_PREDICT = (
"抱歉,我无法预测市场走势或产品涨跌。"
"任何声称可以预测涨跌的信息都不可信,投资有风险,入市需谨慎。"
)
REJECT_COMPARE = (
"抱歉,我无法进行平台或产品间的对比评价。"
"建议您参考各平台的官方信息,做出适合自己的判断。"
)
REJECT_REALTIME = (
"抱歉,游客模式下不支持实时行情查询。"
"如需查看实时行情,请登录后使用行情功能。"
)
REJECT_GENERAL = (
"抱歉,我无法回答这个问题。"
"您可以换一种方式描述,或浏览常见问答获取帮助。"
)
# ---------------------------------------------------------------------------
# 转人工话术(仅限投诉/复杂咨询/用户主动要求/安全问题)
# ---------------------------------------------------------------------------
TRANSFER_TEXT = (
"已为您安排转接人工客服。您也可以:\n"
"- 拨打客服热线 95XXX(工作日 9:00-18:00)\n"
"- 前往最近的营业网点办理\n\n"
"如需查询个人账户、持仓信息或获取个性化投资建议,"
"请登录后联系您的专属理财顾问。\n"
"(游客模式下我无法访问任何账户数据,也无法提供投资建议,敬请谅解。)"
)
FALLBACK_TEXT = (
"抱歉,我暂时无法回答这个问题。您可以:\n"
"1. 换一种方式描述您的问题\n"
"2. 浏览常见问答帮助中心\n"
"3. 拨打客服热线 95XXX\n"
'4. 回复"转人工"联系客服人员'
)
def scan_reply(reply: str) -> tuple[bool, list[str]]:
"""扫描回复是否命中违禁词,返回 (是否命中, 命中词列表)。"""
hits: list[str] = []
for pattern in FORBIDDEN_TERMS:
match = pattern.search(reply)
if match:
hits.append(match.group())
return (len(hits) > 0, hits)
def sanitize_reply(reply: str) -> tuple[str, bool]:
"""命中违禁词则替换为拒绝话术,返回 (safe_reply, need_transfer)。"""
has_forbidden, _ = scan_reply(reply)
if has_forbidden:
return COMPLIANCE_REJECT, True
return reply, False
def should_add_disclaimer(intent: str) -> bool:
"""咨询类意图需要附带风险提示。"""
return intent in ("product_consult", "policy_interpret", "faq")
+179
View File
@@ -0,0 +1,179 @@
"""信息脱敏(CS-C-12,BASE-08):手机号 / 身份证 / 姓名 / 银行卡四类敏感信息脱敏。
运行时机(方案 §7.2,两处防线):
1. LLM 输入前:core_ro 结构化查询结果经 ``mask_dict_fields`` 按字段名批量脱敏后再拼 prompt,
防止敏感信息进入 LLM 上下文或日志;
2. API 返回前:回复文本经 ``mask_text`` 正则兜底扫描(与 compliance_guard 并行的最终防线)。
脱敏规则(方案 §7.1):
- 手机号:保留前 3 后 4,中间 4 个 *(``138****5678``)
- 身份证:保留前 3 后 4(18 位中间 11 个 *;兼容 15 位老证、末位 X)
- 姓名:只保留姓(``张三``→``张*``、``张明明``→``张**``)
- 银行卡:仅保留末 4 位,分隔符(空格/连字符)原样保留(``**** **** **** 7890``)
通用约定:None / 空串原样返回;值中已含 ``*`` 视为已脱敏直接放行(防重复打码);
不符合目标格式的值原样返回(不破坏原始数据)。
"""
from __future__ import annotations
import re
from typing import Any, Callable
# ---------------------------------------------------------------------------
# 单字段脱敏
# ---------------------------------------------------------------------------
def mask_phone(phone: str | None) -> str | None:
"""手机号脱敏:保留前 3 后 4。非 11 位标准手机号原样返回。"""
if not phone or "*" in phone:
return phone
m = re.fullmatch(r"\s*(1[3-9]\d)(\d{4})(\d{4})\s*", phone)
if not m:
return phone
return f"{m.group(1)}****{m.group(3)}"
def mask_id_card(id_no: str | None) -> str | None:
"""身份证脱敏:保留前 3 后 4。支持 18 位(末位可为 X)与 15 位老证。"""
if not id_no or "*" in id_no:
return id_no
s = id_no.strip().upper() # 末位 x 归一为 X
# 18 位:前 3 + 中间 11 位打码 + 后 4(末位可能为 X)
m = re.fullmatch(r"(\d{3})(\d{11})(\d{3}[0-9X])", s)
if m:
return f"{m.group(1)}{'*' * 11}{m.group(3)}"
# 15 位老证:前 3 + 中间 8 位打码 + 后 4
m = re.fullmatch(r"(\d{3})(\d{8})(\d{4})", s)
if m:
return f"{m.group(1)}{'*' * 8}{m.group(3)}"
return id_no
def mask_name(name: str | None) -> str | None:
"""姓名脱敏:只保留姓,其余字符全部打 *(``张三``→``张*``、``张明明``→``张**``)。"""
if not name or "*" in name:
return name
s = name.strip()
if len(s) <= 1:
return s
return s[0] + "*" * (len(s) - 1)
def mask_bank_card(card: str | None) -> str | None:
"""银行卡脱敏:仅保留末 4 位数字,分隔符(空格/连字符)原样保留。
数字位数不足 8 的不当作卡号处理,原样返回。
"""
if not card or "*" in card:
return card
s = card.strip()
digit_count = sum(1 for c in s if c.isdigit())
if digit_count < 8:
return card
keep_from = digit_count - 4 # 第几个数字起保留(从 0 计)
out: list[str] = []
digit_idx = 0
for c in s:
if c.isdigit():
out.append(c if digit_idx >= keep_from else "*")
digit_idx += 1
else:
out.append(c) # 分隔符原样保留
return "".join(out)
# ---------------------------------------------------------------------------
# 自由文本自动扫描(API 返回前兜底)
# ---------------------------------------------------------------------------
# 顺序敏感:先长后短。18 位身份证同样满足银行卡 16~19 位长度,必须先替换身份证;
# 所有模式带数字边界断言,防止 11 位手机号从 16 位卡号中被误切。
_ID_CARD_18_RE = re.compile(r"(?<![0-9Xx])(\d{3})(\d{11})(\d{3}[0-9Xx])(?![0-9Xx])")
_BANK_CARD_RE = re.compile(r"(?<!\d)(?:\d[ -]?){15,18}\d(?!\d)") # 16~19 位,允许空格/连字符分隔
_ID_CARD_15_RE = re.compile(r"(?<!\d)(\d{3})(\d{8})(\d{4})(?!\d)")
_PHONE_RE = re.compile(r"(?<!\d)(1[3-9]\d)(\d{4})(\d{4})(?!\d)")
def _mask_bank_card_match(m: re.Match) -> str:
"""银行卡正则命中片段的替换函数:复用 mask_bank_card 的逐字符逻辑。"""
return mask_bank_card(m.group(0)) # type: ignore[arg-type,return-value]
def mask_text(text: str | None) -> str | None:
"""自由文本兜底脱敏:自动扫描并打码身份证 / 银行卡 / 手机号。
姓名不在自由文本中识别(正则无法可靠区分姓名与普通词语,误伤率高);
姓名脱敏只在结构化字段由 ``mask_name`` / ``mask_dict_fields`` 处理。
"""
if not text:
return text
# 18 位身份证 → 银行卡(16~19 位,含分隔符)→ 15 位老证 → 11 位手机号
text = _ID_CARD_18_RE.sub(lambda m: f"{m.group(1)}{'*' * 11}{m.group(3)}", text)
text = _BANK_CARD_RE.sub(_mask_bank_card_match, text)
text = _ID_CARD_15_RE.sub(lambda m: f"{m.group(1)}{'*' * 8}{m.group(3)}", text)
text = _PHONE_RE.sub(lambda m: f"{m.group(1)}****{m.group(3)}", text)
return text
# ---------------------------------------------------------------------------
# 结构化数据批量脱敏(LLM 输入前,core_ro 查询结果)
# ---------------------------------------------------------------------------
# 字段名 → 脱敏类型。core_ro 返回的行是扁平 dict,按列名命中即脱敏;
# 库中 *_mask 列本已脱敏,命中后会被 mask_* 的 "*" 检查直接放行(纵深防御)。
DEFAULT_FIELD_MASKERS: dict[str, str] = {
# 手机号
"phone": "phone",
"phone_mask": "phone",
"mobile": "phone",
# 身份证
"id_no": "id_card",
"id_no_mask": "id_card",
"id_card": "id_card",
# 姓名
"name": "name",
"display_name": "name",
"customer_name": "name",
"counterparty_name": "name",
"payer_name": "name",
# 银行卡 / 账号
"bank_card": "bank_card",
"card_no": "bank_card",
"account_no": "bank_card",
"counterparty_account_mask": "bank_card",
}
_MASKER_FUNCS: dict[str, Callable[[str | None], str | None]] = {
"phone": mask_phone,
"id_card": mask_id_card,
"name": mask_name,
"bank_card": mask_bank_card,
}
def mask_dict_fields(
data: Any,
field_maskers: dict[str, str] | None = None,
) -> Any:
"""对 core_ro 结构化查询结果按字段名批量脱敏。
- 入参可为 dict(单行)或 list[dict](多行,如 list_holdings / list_trades 结果);
- 仅处理字符串值,非字符串(Decimal/date/int 等)原样保留;
- 返回浅拷贝,不修改入参;
- ``field_maskers`` 可自定义 {字段名: 脱敏类型},脱敏类型取
phone / id_card / name / bank_card;默认用 :data:`DEFAULT_FIELD_MASKERS`。
"""
mapping = field_maskers or DEFAULT_FIELD_MASKERS
if isinstance(data, list):
return [mask_dict_fields(item, mapping) if isinstance(item, dict) else item for item in data]
if not isinstance(data, dict):
return data
out = dict(data)
for field, masker_name in mapping.items():
if field in out and isinstance(out[field], str):
out[field] = _MASKER_FUNCS[masker_name](out[field])
return out