From ce445d0fb898c973779c8345713548866c923083 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 29 Oct 2026 19:11:39 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B0=BA=E5=AF=B8=E6=8A=A5?= =?UTF-8?q?=E4=BB=B7=E5=8F=8A=E7=9B=AE=E7=9A=84=E5=9C=B0=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E7=9A=84=E4=B8=8A=E4=B8=8B=E6=96=87=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.md | 4 +-- logistics_chat/chat_service.py | 12 ++++--- logistics_chat/order_data.py | 2 ++ logistics_chat/pricing.py | 66 +++++++++++++++++----------------- tests/test_api.py | 30 ++++++++++++++++ tests/test_order_data.py | 8 +++-- tests/test_rules.py | 7 ++++ 7 files changed, 87 insertions(+), 42 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 9cd1efc..fac1691 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,13 +105,13 @@ tests/ # 按业务模块组织的回归 ## 必须保留的接口与数据约定 - SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`;客户端兼容 LF/CRLF 和跨字节分段,收到 `d` 才算成功,意外 EOF 保留已收到正文并提供重试。上游缺少结束标记或因长度截断时保存中断状态;压缩、追问、流式 HTTP 响应都要关闭。首段等待 90 秒、整轮 120 秒;会话、历史及人工 JSON 请求 10 秒,上传 15 秒超时可恢复。 -- `chat_service.create_chat_stream` 的订单查询使用本地 `order_test_data`;忽略附件路径和重量/日期,保留标识中的后缀,不再调用 `build_track` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。 +- `chat_service.create_chat_stream` 的订单查询使用本地 `order_test_data`;忽略附件路径、重量、日期和三维尺寸,保留标识中的后缀,不再调用 `build_track` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。 - `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。子单没有独立货物数据时不继承父单总重量;COMPLETED 只表示头程订单完成,不表示快递签收。页面和模型上下文只显示订单事实,不附加测试副本声明或导入时间;旧历史订单卡片中的相关元数据也不展示。订单追问只在同一 `conv_id` 内复用最近单号,并重新查表取得手工修改后的值;仅明确指向上一票或简短字段追问可复用,新报价或其他话题不会沿用旧订单。 - 订单数据仅为内部验收副本:`ORDER_DATA_TENANT_ID` 可固定服务器端租户范围,空值允许查询内部已导入样本。它不代替将来客户登录授权。没有管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。源库连接凭据和快照文件均在仓库外,业务库只读,不随服务查询访问源库。 - 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。历史正文和卡片使用存库时间,卡片恢复复制操作。切换话题或历史时取消旧流和待上传附件;等待建会话期间的新草稿不能被旧发送清空。历史刷新失败保留已有列表;删除或清空成功后先同步本地列表,并使先前的刷新失效,刷新失败或迟到均不恢复已删除记录。删除会话同步移除其人工会话及消息,迟到的模型写入不能生成孤立消息。 - 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent/system`。转接将 active 改为 waiting,列表只允许主动接入。备注保存在 `human_sessions.note`;撤回将原客服消息改为系统提示并追加带 `recalled_id` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。 - 新建会话保存一条 assistant 欢迎语并返回 `welcome`,前端在首次用户消息之前显示。首次提问判定只统计 user 消息。服务评价用 `ratings` 保存,并汇总到管理统计。Excel 导出依赖 `openpyxl`,原 CSV 可用 `?format=csv` 访问。 -- 计费档位、单位换算和舍入保持原样。`shipping_intent_with_context` 用 `is_shipping_followup` 识别渠道、重量、尺寸等简短追问及重试,保留报价条件;新订单、理赔及新的完整问题形成回溯边界。中英混写的 UK/US 按 ASCII 字母边界识别;目的地未确认时先询问目的国家,不默认给出欧洲报价。英国使用英国渠道和默认比价组合。明确提到美国等未覆盖目的地时不套用欧洲费率,英国按方报价不套用欧洲仓价;指定国家或仓点没有匹配时 `calc_warehouse_card` 返回空。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。 +- 计费档位、单位换算和舍入保持原样。`shipping_intent_with_context` 用 `is_shipping_followup` 识别渠道、重量、尺寸、单独目的地回复及重试,保留报价条件;新订单、另外一票、理赔及新的完整问题形成回溯边界,不继承上一票尺寸。尺寸支持带小数和空格,换算单位后计价。中英混写的 UK/US 按 ASCII 边界识别;目的地未确认时先询问目的国家,不默认给出欧洲报价。英国使用英国渠道和默认比价组合。明确提到美国等未覆盖目的地时不套用欧洲费率,英国按方报价不套用欧洲仓价;指定国家或仓点没有匹配时 `calc_warehouse_card` 返回空。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。 - 聊天请求的会话编号仅接受 SQLite 整数范围内的 ASCII 十进制整数或空值;字符串转换前限制长度,非法 Unicode 数字和超长数字返回 SSE 输入错误,不进入模型调用。 - 上传 URL 保留 `/uploads/`,默认 10MB,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。 - `agents`、`orders`、`tickets` 在应用初始化时建空表;兼容已有独立 id 列的 agents 表,不预置账号或演示数据。 diff --git a/logistics_chat/chat_service.py b/logistics_chat/chat_service.py index e54488e..6dc5bf7 100644 --- a/logistics_chat/chat_service.py +++ b/logistics_chat/chat_service.py @@ -9,7 +9,8 @@ from .config import get_setting from .db import connect, last_user_question, load_context, save_message from .knowledge import search_knowledge from .pricing import (parse_shipping_intent, detect_dim_unit, freight_reply, - calc_warehouse_card, detect_surcharge, surcharge_reply, check_island) + calc_warehouse_card, detect_surcharge, surcharge_reply, check_island, + is_shipping_destination_reply) from .tracking import CARD_PREFIX from .order_data import answer_query, extract_reference @@ -26,13 +27,15 @@ QUOTE_FOLLOWUP_PREFIX = r'^(?:那(?:么)?|换成|改成|改为|如果|还是|再 def is_shipping_followup(message, intent=None): """Recognize bounded quote edits without carrying facts across new topics.""" - if re.search(r'新(?:的)?一(?:票|批)|另(?:一|外)(?:票|批)|新货|新订单|换个话题|理赔|赔偿|破损|丢失|签收|退件|退款|投诉', message): + if re.search(r'新(?:的)?一(?:票|批)|另(?:一|外一?)(?:票|批)|新(?:的)?(?:货|订单)|换个话题|理赔|赔偿|破损|丢失|签收|退件|退款|投诉', message): return False intent = intent if intent is not None else parse_shipping_intent(message) text = message.strip().strip(',,。.!!??、:: ') prefix = re.match(QUOTE_FOLLOWUP_PREFIX, text) if prefix and any(intent.get(key) is not None for key in ('weight', 'dims', 'channel', 'region')): return True + if intent['region'] is not None and is_shipping_destination_reply(text): + return True short = re.sub(QUOTE_FOLLOWUP_PREFIX, '', text, count=1).strip() short = re.sub(r'^(?:请问|请|再问一下)', '', short).strip() if re.fullmatch(r'(?:海运|海派|空运|空派|铁路|班列|卡航|卡车|sea(?:\s+freight)?|air(?:\s+freight)?|rail|truck)' @@ -42,7 +45,7 @@ def is_shipping_followup(message, intent=None): return True if intent['weight'] is not None and re.fullmatch(r'\d+(?:\.\d+)?\s*(?:kg|公斤|千克|吨|lbs?|tons?|kilograms?)(?:呢)?', short, re.I): return True - return bool(intent['dims'] and re.fullmatch(r'\d+\s*[*xX×]\s*\d+\s*[*xX×]\s*\d+\s*(?:cm|mm|m|厘米|毫米|米)?(?:呢)?', short, re.I)) + return bool(intent['dims'] and re.fullmatch(r'\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*(?:cm|mm|m|厘米|毫米|米)?(?:呢)?', short, re.I)) def shipping_intent_with_context(message, conv_id): @@ -95,7 +98,8 @@ def create_chat_stream(msg, conv_id): re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) or (ship['channel'] and (is_shipping_followup(msg, ship) or re.search(r'报价|价格|费用|运费|多少钱|\b(?:quote|cost|price)\b', msg, re.I)))): order_answer = {'t':'请提供目的国家或地区、目的仓或邮编,以便匹配对应费率;当前无法确定报价,可联系人工客服确认。'} - if ship and ship['region'] == 'unsupported' and (ship['weight'] is not None or ship['dims'] or ship['channel'] or re.search(r'报价|价格|运费|费用|多少钱', msg)): + if ship and ship['region'] == 'unsupported' and (ship['weight'] is not None or ship['dims'] or ship['channel'] or + is_shipping_destination_reply(msg) or re.search(r'报价|价格|运费|费用|多少钱', msg)): order_answer = {'t':'当前报价表尚未包含该目的地的费率,请提供目的仓或邮编、货物品名、件数和尺寸,由人工客服确认报价。'} if ship and ship['region'] == 'uk' and re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) and not ship['weight'] and not ship['dims']: order_answer = {'t':'当前报价表未提供该目的地的按方费率,请提供目的仓或邮编、实际重量和尺寸,由人工客服确认报价。'} diff --git a/logistics_chat/order_data.py b/logistics_chat/order_data.py index 046e3ac..2a0c69c 100644 --- a/logistics_chat/order_data.py +++ b/logistics_chat/order_data.py @@ -100,6 +100,8 @@ def extract_reference(message): message = re.sub(r'(? 0 and t[m.start(1) - 1].isdigit(): - ok = False - break - dims.append(int(m.group(g))) - if ok: - return [float(x) for x in dims] - return None + match = re.search(r'(?