Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33e166b104 | ||
|
|
7b3b3d30ba | ||
|
|
311b9bd57d | ||
|
|
d5692434ac | ||
|
|
5f1c4043d4 |
@@ -9,14 +9,14 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
|
||||
| AI | `logistics_chat/chat_service.py`: `create_chat_stream`; `logistics_chat/ai_client.py`: `post_completion` |
|
||||
| Voice input | `static/js/voice.js`: `ChatVoice.create`; `logistics_chat/speech_service.py`: `transcribe_recording`, `baidu_transcribe`; `logistics_chat/routes/speech.py` |
|
||||
| Guest identity | `static/js/visitor.js`: bootstrap and cookie recovery; `logistics_chat/visitor_service.py`: owner checks; `routes/visitor.py`: customer human support |
|
||||
| Customer login | `logistics_chat/customer_service.py`: sessions, default customer bootstrap, order conversation binding; `routes/customer.py`; `static/js/customer.js`: login and logout |
|
||||
| Customer login / overview | `logistics_chat/customer_service.py`: sessions, default customer bootstrap, order conversation binding; `routes/customer.py`; `static/js/customer.js`: login and logout; `order_listing.py`: `read_overview`, `answer_overview` for chat replies |
|
||||
| Order lookup UI | `static/js/order_lookup.js`: `OrderLookup.create`; `chat.js`: `showOrderLookup`, `fillTrackCard`; `support.js`: `copyText` |
|
||||
| Chat drafts | `static/js/drafts.js`: `ChatDrafts.create`; `chat.js`: `flushDraft`, `restoreDraft`, startup recovery |
|
||||
| Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` |
|
||||
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `quote_intent.py`: `parse_intent`; `quote_service.py`: `answer_quote`; `quote_engine.py`: `quantity`, `render_rates`; `quote_answers.py`, `quote_fees.py`: local business terms and fees |
|
||||
| US quote data | `logistics_chat/quote_workbook.py`: local detail export; `quote_data.py`: versioned SQLite imports; `quote_engine.py`: matching/calculation; `quote_service.py`: conversational reply; `scripts/import_quote_data.py` |
|
||||
| Common customer questions | `logistics_chat/shipping_faq.py`: pre-shipment material; `quote_allocations.py`: per-warehouse weights; `quote_context.py`: chronological edits; `order_explanations.py`: business status, latest actual nodes and operation boundaries |
|
||||
| Reply boundaries / list pages | `logistics_chat/customer_faq.py`: supported actions; `quote_scope.py`: US-only fallback; `quote_edits.py`: rejected conditions; `quote_material.py`: partial metal chargeable weight; `order_listing.py`: scoped counts and conversation pages |
|
||||
| Reply boundaries / order summaries | `logistics_chat/customer_faq.py`: supported actions; `quote_scope.py`: US-only fallback; `quote_edits.py`: rejected conditions; `quote_material.py`: partial metal chargeable weight; `order_query.py`: bounded filters; `order_listing.py`: scoped five-row summaries and conversation pages; `order_tracking.py`: `current_stage` |
|
||||
| Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` |
|
||||
| Order test data | `logistics_chat/order_data.py`: `lookup_order`, `count_orders`, `answer_query`, `import_records`; `scripts/import_order_test_data.py` |
|
||||
| Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` |
|
||||
|
||||
+10
-2
@@ -113,7 +113,7 @@ tests/ # 按业务模块组织的回归
|
||||
- `chat_service.create_chat_stream` 的订单查询使用本地 `order_test_data`;忽略附件路径、重量、日期和三维尺寸,保留标识中的后缀,不再调用 `build_track` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。
|
||||
- `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。`order_tracking.py` 从 `order_test_data.tracking_json` 的已关联事实生成物流轨迹;存在有效事实时设置 `node_kind=logistics`,`nodes` 仅存实际节点,`estimated_nodes` 单独展示预计节点,`tracking_status` 只按实际节点序号推进。相同货柜/节点按最新观测修正时间,预计事实不覆盖实际事实;多柜独立展示。缺失的节点、地点和签收事实不得补造,提柜、还柜不等于签收。子单不继承父单重量或轨迹;COMPLETED 只表示头程订单完成。没有物流事实时继续展示业务状态历史。页面和模型上下文不附加测试副本声明或导入时间。订单及物流追问只在同一 `conv_id` 内复用最近单号,并重新查表取得修改后的值;新报价或其他话题不会沿用旧订单。
|
||||
- 订单表不提供管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。匿名 HTTP 查单在读取订单前返回登录引导;`g.customer_tenant_id` 由可信客户认证适配器设置,不接受访客 key、请求参数或客户自报 Header。客户范围非空时必须传给 `lookup_order`,并与 `ORDER_DATA_TENANT_ID` 的额外限制取交集。源库连接凭据和快照在仓库外,业务库只读,不随服务查询访问源库。
|
||||
- 登录后的订单数量查询由 `parse_count_query` 识别,`count_orders` 使用与单号查询相同的 `tenant_filter` 交集规则,必须显式提供非空客户范围;SQL 直接计算订单票数和已登记入仓码的订单票数,按需过滤明确的业务状态。无额外筛选时统计授权范围全部订单,不复用上一票单号;无法识别的附加条件不静默丢弃,也不交给模型猜数。成功统计返回的内部 `_customer_data` 标记由 `chat_service` 消费并移除,使纯文本统计同样在保存/发送前绑定会话客户和范围;不增加公开 SSE 字段或管理接口。
|
||||
- 登录后的订单数量由 `parse_count_query` / `order_query.parse_query` 识别,汇总、筛选和列表共用 `order_listing.read_orders` 的服务端 `tenant_filter` 交集与同一只读快照;`count_orders` 保留原聚合调用契约。默认给总数、已登记入仓码数及最多5条按来源更新时间排序的摘要;支持业务状态、明确标记的异常和当前实际节点。未指定节点的轨迹统计按订单分组,每票只计一次,多柜不同进度单列,预计节点不推进进度,业务完成不当作签收。未知条件不默认为全部,也不交给模型猜数。内部 `_customer_data` 和 `_order_list_state` 在发送前移除,事实历史绑定客户与范围;无新增公开API。
|
||||
- 客户认证使用独立 `customer_users`、`customer_tenant_grants` 与 `customer_sessions` 表,由 `customer_service.attach_customer` 在访客恢复之后验证并设置 `g.customer`、单客户 `g.customer_tenant_id` 或多客户 `g.customer_tenant_ids`;只保存密码哈希与登录 token 哈希。默认账号首次关联当前本地订单的客户范围,之后不自动扩大,普通账号仍按明确关联过滤;`lookup_order` 的单客户参数及多客户白名单都与环境限制取交集。会话的 `customer_scope` 保存范围指纹。登录 Cookie 为 HttpOnly、SameSite=Lax,在 HTTPS 上为 Secure,绑定当前访客并在 8 小时后失效;退出、账号停用、密码或客户范围改变使旧会话失效。默认客户账号不赋予客服后台权限。
|
||||
- 匿名查单返回 SSE `login_required` 与正文,客户页提供登录按钮;`customer.js` 登录成功后仅在原会话版本未变化时继续原问题,保留期间输入的新草稿。登录过期导致界面清空时,只允许同一客户重新通过历史归属校验后恢复原会话并续查;换账号、访问范围变化、历史加载失败或切换话题均不重放旧问题。侧栏可独立登录/退出,登录状态在窗口恢复焦点时刷新。首次返回订单卡片时,会话绑定客户 ID 和客户范围;历史、人工会话及后续生成同时校验访客、客户和范围。退出立即清除当前可见订单内容和历史标题缓存,重新登录同一身份可恢复;匿名清空历史不删除被登录保护的会话。
|
||||
- 底部及欢迎卡片的查入仓码入口先确认客户登录,再打开 `OrderLookup`,不自动新建会话或发送缺少号码的问题。当前及历史订单卡片的号码也打开该查询框,保留真实的入仓码/订单号标签,支持单独复制和重新查询。提交使用既有 `/stream_chat` 并显示明确的用户问句,结果追加到当前会话;主输入框草稿及已上传附件不清空、不代发。人工接待、未加载历史或其他请求进行中时不抢占路由。切换话题/身份关闭弹窗,迟到的登录/状态/复制结果不得改动新上下文;弹窗跟随手机可视视口并将焦点还给触发按钮。
|
||||
@@ -149,7 +149,15 @@ tests/ # 按业务模块组织的回归
|
||||
|
||||
`quote_edits.py` 先移除被明确否定的仓码或交货仓,再解析替换条件。共享尺寸解析支持英寸转换和口语长宽高,“一个箱子”可形成单箱条件;总计立方米不作为尺寸单位“米”。
|
||||
|
||||
`order_listing.py` 负责列表与下一页/上一页/指定页码,每页20票;从当前会话最近的列表回复恢复页码和业务状态,重新以服务端客户范围查询实际总数和该页数据。COUNT与分页读取处于同一只读事务;无有效列表上下文时要求先列订单,不推测剩余数量。页码有界,筛选及翻页回复与订单事实一样保护历史,不增加公开API或管理入口。
|
||||
`order_listing.py` 负责数量摘要、查看全部和翻页。数量摘要最多5票,直接翻页继续每页5票;明确列出订单或“查看全部”按每页20票。`chat_history.order_list_state` 保存筛选、页码、页大小和成功回复的消息ID,绑定客户与范围;只保存仍为最新消息的状态,迟到回复不能覆盖新话题。中间插问同条件总数保留列表页码,改筛选重置,新话题停止沿用。每次重新查询授权数据,不缓存数量;计数及分页来自同一只读快照,无有效上下文先引导查询,页码有界。
|
||||
|
||||
`order_test_data.exception_reason` 是人工明确登记、允许客户查看的物流异常原因;非空表示该票有异常标记,空值表示未标记,不推断货物正常。取消、审核驳回和无轨迹均不自动标为物流异常。可直接维护此字段,不增加管理页面;旧快照未提供该字段时即使显式覆盖也保留已有原因,显式提供空字符串才清除。后续MCP适配返回相同事实即可。
|
||||
|
||||
用户询问“订单概览”“我的订单情况”时,`answer_overview` 在现有聊天流中回复,沿用订单事实的登录校验与会话保存。`read_overview` 复用 `scoped_orders`,在同一只读快照内计算全部订单、业务状态 PROCESSING 的履约中订单、明确异常数量和履约中订单的实际节点分布。最多5条重点订单优先列异常,其余按更新时间列履约中订单。结果仅出现在聊天消息及对应历史中,登录和恢复登录只更新身份状态。
|
||||
|
||||
语音反馈使用输入框上方的绝对定位浮层,不占输入区布局高度,4秒后收起。切换对话清除反馈,同一手势失败后须松开才能重新录音,Pointer、Touch 与键盘入口遵循同一规则。
|
||||
|
||||
共享尺寸解析将单位与长宽高绑定,支持每边单位和混合单位,不受“12英寸显示器”等商品描述影响。报价沿当前会话回溯到最近话题边界,不按10轮截断;其他国家、新一票及订单话题隔离不变。“铁铝部分还是10KG”等口语仅在先前已明确计费重时更新该基数,既不覆盖整票总重,也不把未确认毛重直接当计费重。
|
||||
|
||||
已确认海派计费重保留原小数,不向上取整,每箱最低12KG仍生效。原实重小数原样展示;无限小数的材积结果以“约”展示有限位数,金额计算仍使用原Decimal精度,不把显示近似当作计费进位。
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ def create_chat_stream(msg, conv_id):
|
||||
order_answer = {'t':'当前报价表未提供该目的地的按方费率,请提供目的仓或邮编、实际重量和尺寸,由人工客服确认报价。'}
|
||||
if order_answer is not None:
|
||||
customer_data = order_answer.pop('_customer_data',False) or 'card' in order_answer
|
||||
order_list_state=order_answer.pop('_order_list_state',None)
|
||||
|
||||
def track_stream():
|
||||
if customer_data:
|
||||
@@ -125,7 +126,10 @@ def create_chat_stream(msg, conv_id):
|
||||
save_message(conv_id, 'user', msg)
|
||||
content = (CARD_PREFIX + json.dumps(order_answer['card'], ensure_ascii=False)
|
||||
if 'card' in order_answer else order_answer['t'])
|
||||
save_message(conv_id, 'assistant', content)
|
||||
message_id=save_message(conv_id, 'assistant', content)
|
||||
if order_list_state is not None:
|
||||
from .order_listing import save_state
|
||||
save_state(conv_id,order_list_state,message_id)
|
||||
yield sse(order_answer)
|
||||
yield sse({'d': 1})
|
||||
return (track_stream(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
|
||||
|
||||
@@ -54,6 +54,8 @@ def init_db():
|
||||
cur.execute('ALTER TABLE chat_history ADD COLUMN visitor_id TEXT')
|
||||
if 'customer_id' not in cols:
|
||||
cur.execute('ALTER TABLE chat_history ADD COLUMN customer_id INTEGER')
|
||||
if 'order_list_state' not in cols:
|
||||
cur.execute('ALTER TABLE chat_history ADD COLUMN order_list_state TEXT')
|
||||
if 'customer_scope' not in cols:
|
||||
cur.execute('ALTER TABLE chat_history ADD COLUMN customer_scope TEXT')
|
||||
if 'customer_tenant_id' in cols:
|
||||
@@ -232,6 +234,7 @@ def save_message(conv_id, role, content):
|
||||
if not inserted:
|
||||
conn.close()
|
||||
return 'deleted'
|
||||
message_id=cur.lastrowid
|
||||
if role == 'user':
|
||||
row = cur.execute('SELECT title FROM chat_history WHERE id=?', (conv_id,)).fetchone()
|
||||
if row and (not row[0] or row[0] == '新对话'):
|
||||
@@ -239,6 +242,7 @@ def save_message(conv_id, role, content):
|
||||
cur.execute('UPDATE chat_history SET utime=? WHERE id=?', (now_str(), conv_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return message_id
|
||||
|
||||
|
||||
def last_user_question(conv_id):
|
||||
|
||||
@@ -14,7 +14,7 @@ from .customer_service import order_login_response
|
||||
FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_id',
|
||||
'status', 'country_code', 'transport_mode', 'warehouse_code', 'order_role',
|
||||
'parent_order_no', 'package_count', 'gross_weight_kg', 'volume_cbm',
|
||||
'history_json', 'tracking_json', 'data_note', 'source_updated_at', 'snapshot_at')
|
||||
'history_json', 'tracking_json', 'exception_reason', 'data_note', 'source_updated_at', 'snapshot_at')
|
||||
STATUS_LABELS = {'DRAFT': '草稿', 'SUBMITTED': '已提交', 'APPROVED': '审核通过',
|
||||
'ACCEPTED': '已接单', 'REJECTED': '审核驳回', 'PROCESSING': '履约中',
|
||||
'COMPLETED': '头程订单已完成', 'CANCELLED': '已取消'}
|
||||
@@ -23,7 +23,7 @@ FIELD_LABELS = {'inbound':'入仓码', 'number':'订单号', 'shipment':'Shipmen
|
||||
'weight':'申报毛重', 'volume':'申报体积', 'role':'订单层级',
|
||||
'parent':'父订单号', 'updated':'来源更新时间'}
|
||||
IDENTIFIER = re.compile(r'(?<![A-Za-z0-9_-])[A-Za-z0-9][A-Za-z0-9_-]{5,63}(?![A-Za-z0-9_-])')
|
||||
ORDER_DETAIL = r'状态|进度|轨迹|到哪|开船|到港|清关|提柜|还柜|签收|预计|什么时候|何时|多久|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格|几件|几箱|公斤|千克|多少钱'
|
||||
ORDER_DETAIL = r'状态|进度|轨迹|异常|到哪|开船|到港|清关|提柜|还柜|签收|预计|什么时候|何时|多久|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格|几件|几箱|公斤|千克|多少钱'
|
||||
|
||||
|
||||
class OrderDataUnavailable(Exception):
|
||||
@@ -39,13 +39,15 @@ def init_order_table(connection):
|
||||
status TEXT NOT NULL, country_code TEXT, transport_mode TEXT, warehouse_code TEXT,
|
||||
order_role TEXT, parent_order_no TEXT,
|
||||
package_count INTEGER, gross_weight_kg TEXT, volume_cbm TEXT,
|
||||
history_json TEXT NOT NULL DEFAULT '[]', tracking_json TEXT NOT NULL DEFAULT '[]', data_note TEXT,
|
||||
history_json TEXT NOT NULL DEFAULT '[]', tracking_json TEXT NOT NULL DEFAULT '[]', exception_reason TEXT, data_note TEXT,
|
||||
source_updated_at TEXT, snapshot_at TEXT,
|
||||
UNIQUE(tenant_id, order_no)
|
||||
)''')
|
||||
columns = {row[1] for row in connection.execute('PRAGMA table_info(order_test_data)')}
|
||||
if 'tracking_json' not in columns:
|
||||
connection.execute("ALTER TABLE order_test_data ADD COLUMN tracking_json TEXT NOT NULL DEFAULT '[]'")
|
||||
if 'exception_reason' not in columns:
|
||||
connection.execute('ALTER TABLE order_test_data ADD COLUMN exception_reason TEXT')
|
||||
connection.execute('CREATE INDEX IF NOT EXISTS idx_order_test_inbound ON order_test_data(inbound_code)')
|
||||
connection.execute('CREATE INDEX IF NOT EXISTS idx_order_test_shipment ON order_test_data(shipment_id)')
|
||||
|
||||
@@ -62,6 +64,10 @@ def import_records(connection, records, replace=False):
|
||||
row['source_order_id'] = str(row['source_order_id']) if row['source_order_id'] is not None else None
|
||||
row['order_no'] = str(row['order_no']).strip()
|
||||
row['status'] = str(row['status'] or 'DRAFT')
|
||||
if row['exception_reason'] is not None:
|
||||
if not isinstance(row['exception_reason'],str) or len(row['exception_reason'])>2000:
|
||||
raise ValueError('exception_reason must be customer-visible text up to 2000 characters')
|
||||
row['exception_reason']=row['exception_reason'].strip() or None
|
||||
history = row['history_json'] or []
|
||||
if isinstance(history, str): history = json.loads(history)
|
||||
if not isinstance(history, list): raise ValueError('history_json must contain a list')
|
||||
@@ -78,7 +84,8 @@ def import_records(connection, records, replace=False):
|
||||
continue
|
||||
columns = ','.join(FIELDS)
|
||||
placeholders = ','.join('?' for _ in FIELDS)
|
||||
updates = ','.join(key+'=excluded.'+key for key in FIELDS if key not in ('tenant_id', 'order_no'))
|
||||
updates = ','.join(key+'=excluded.'+key for key in FIELDS if key not in ('tenant_id', 'order_no')
|
||||
and (key!='exception_reason' or key in source))
|
||||
connection.execute(f'INSERT INTO order_test_data ({columns}) VALUES ({placeholders}) '
|
||||
f'ON CONFLICT(tenant_id,order_no) DO UPDATE SET {updates}', [row[key] for key in FIELDS])
|
||||
inserted += 1
|
||||
@@ -179,44 +186,14 @@ def count_orders(tenant_ids, status=None):
|
||||
|
||||
|
||||
def parse_count_query(message):
|
||||
"""Recognize bounded total/status questions without ignoring unknown filters."""
|
||||
if is_lookup_help(message):
|
||||
return None
|
||||
noun = r'订单|运单|入仓码|入仓号|\borders?\b|\bshipments?\b'
|
||||
count = r'多少|几(?:个|票|条|张|笔)|总数|数量|总量|统计|数一下|(?:订单|运单)数(?!量)|\bhow\s+many\b|\bcount\b|\bnumber\b|\btotal\b'
|
||||
if not re.search(noun,message,re.I) or not re.search(count,message,re.I):
|
||||
return None
|
||||
if re.search(r'公斤|千克|\bkg\b|重量|毛重|体积|件数|箱数|运费|费用|价格|多少钱',message,re.I):
|
||||
return None
|
||||
remaining = message
|
||||
statuses = []
|
||||
for phrase, status in (('头程订单已完成','COMPLETED'),('已完成','COMPLETED'),('履约中','PROCESSING'),
|
||||
('处理中','PROCESSING'),('已取消','CANCELLED'),('审核通过','APPROVED'),
|
||||
('审核驳回','REJECTED'),('已接单','ACCEPTED'),('已提交','SUBMITTED'),('草稿','DRAFT')):
|
||||
if phrase in remaining:
|
||||
statuses.append(status)
|
||||
remaining = remaining.replace(phrase,'')
|
||||
remaining = re.sub(count+'|'+noun,'',remaining,flags=re.I)
|
||||
remaining = re.sub(r'当前账号|当前账户|账号下|账户下|名下|可查询|能查到|可以查到|目前|现在|当前|一共|总共|合计|总计|'
|
||||
r'请问|麻烦|帮我|想知道|想查|想看|请|查询|查一下|查下|查查|看一下|看看|看下|一下|下|全部|所有|共有|我的|我|能|有|的|个|票|条|张|笔|呢|吗|呀|啊|共|'
|
||||
r'\b(?:what|is|of|do|i|have|are|there|my|in|all|the|currently)\b|[\s,,。.!!??、::]','',remaining,flags=re.I)
|
||||
return {'status':statuses[0] if statuses else None, 'unsupported':bool(remaining or len(set(statuses)) > 1)}
|
||||
if is_lookup_help(message):return None
|
||||
from .order_query import parse_query
|
||||
return parse_query(message)
|
||||
|
||||
|
||||
def answer_count_query(intent, tenants):
|
||||
if not tenants:
|
||||
return order_login_response()
|
||||
if intent['unsupported']:
|
||||
return {'t':'暂不支持按这个条件统计。可以查询订单总数,或按草稿、已提交、履约中、已完成、已取消等业务状态统计。'}
|
||||
try:
|
||||
counts = count_orders(tenants,intent['status'])
|
||||
except OrderDataUnavailable:
|
||||
return {'t':'订单信息暂时无法读取,请稍后重试。'}
|
||||
if intent['status']:
|
||||
text = f"您当前有 {counts['total']} 票业务状态为“{STATUS_LABELS[intent['status']]}”的订单"
|
||||
else:
|
||||
text = f"您当前共有 {counts['total']} 票订单"
|
||||
return {'t':text + f",其中 {counts['with_inbound']} 票已登记入仓码。", '_customer_data':True}
|
||||
def answer_count_query(intent, tenants,conv_id=None):
|
||||
from .order_listing import answer_summary
|
||||
return answer_summary(intent,tenants,conv_id)
|
||||
|
||||
|
||||
def answer_list_query(message, tenants, conv_id=None):
|
||||
@@ -233,6 +210,14 @@ def extract_reference(message):
|
||||
message = re.sub(r'(?<![A-Za-z0-9_-])\d{4}-\d{1,2}-\d{1,2}'
|
||||
r'(?:[T ]\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?'
|
||||
r'(?![A-Za-z0-9_-])', ' ', message)
|
||||
from .pricing import DIMENSION_PATTERNS
|
||||
for pattern in DIMENSION_PATTERNS:
|
||||
def remove_dimensions(match):
|
||||
end=match.start()+len(match[0].rstrip())
|
||||
before=message[match.start()-1:match.start()] if match.start() else ''
|
||||
after=message[end:end+1]
|
||||
return match[0] if re.search(r'[A-Za-z0-9_.-]',before+after) else ' '
|
||||
message=pattern.sub(remove_dimensions,message)
|
||||
message = re.sub(r'(?<![A-Za-z0-9_.-])\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?'
|
||||
r'(?:\s*(?:inches|inch|in|cm|mm|m|英寸|厘米|毫米|米|["″]))?(?![A-Za-z0-9_.-])', ' ', message, flags=re.I)
|
||||
message = re.sub(r'(?<![A-Za-z0-9_-])\d+(?:\.\d+)?\s*'
|
||||
@@ -350,11 +335,14 @@ def answer_query(message, conv_id):
|
||||
tenants = customer_tenant_ids()
|
||||
from .order_explanations import general_explanation, detail_question, explain_order
|
||||
if not references:
|
||||
from .order_listing import answer_overview
|
||||
overview=answer_overview(message,tenants)
|
||||
if overview is not None:return overview
|
||||
explanation=general_explanation(message)
|
||||
if explanation:return {'t':explanation}
|
||||
count_intent = parse_count_query(message) if not references else None
|
||||
if count_intent is not None:
|
||||
return answer_count_query(count_intent,tenants)
|
||||
return answer_count_query(count_intent,tenants,conv_id)
|
||||
if not references:
|
||||
listed=answer_list_query(message,tenants,conv_id)
|
||||
if listed is not None:return listed
|
||||
|
||||
@@ -6,7 +6,7 @@ import re
|
||||
def detail_question(message):
|
||||
if re.search(r'报价|运费|海派|海卡|\d+\s*(?:KG|公斤)',message,re.I) and not re.search(r'这票|该订单|我的订单',message):
|
||||
return False
|
||||
return bool(re.search(r'上架|没更新|未更新|没有更新|不更新|少了?.*(?:箱|件)|到了?几箱|逐箱|每箱.*签收|'
|
||||
return bool(re.search(r'异常原因|为什么异常|什么异常|上架|没更新|未更新|没有更新|不更新|少了?.*(?:箱|件)|到了?几箱|逐箱|每箱.*签收|'
|
||||
r'已完成.*(?:签收|送达)|(?:签收|送达).*已完成|催单|催一下|拦截|改.*(?:收货|收件).*地址|(?:收货|收件)地址.*改|快递单号|付过款|付款状态|'
|
||||
r'(?:这票|该订单|我的货).*(?:发货|发走|出库|开船)',message))
|
||||
|
||||
@@ -22,6 +22,9 @@ def explain_order(message,row,card):
|
||||
if not detail_question(message):return None
|
||||
reference=row.get('inbound_code') or row['order_no']
|
||||
lines=[f'入仓码 {reference}。' if row.get('inbound_code') else f'订单号 {reference}。']
|
||||
if re.search(r'异常原因|为什么异常|什么异常',message):
|
||||
reason=(row.get('exception_reason') or '').strip()
|
||||
return lines[0]+('已记录的异常原因:'+reason if reason else '当前未登记异常原因,请联系人工核实。')
|
||||
if re.search(r'催单|催一下|拦截',message):
|
||||
return lines[0]+'催单或拦截需联系人工客服办理;当前仅查询订单,尚未提交催单或拦截申请。'
|
||||
if re.search(r'付过款|付款状态',message):
|
||||
|
||||
+243
-48
@@ -1,9 +1,13 @@
|
||||
"""Bounded conversational pagination over the server-validated customer scope."""
|
||||
"""Scoped summaries, five-row previews and durable conversational list pages."""
|
||||
|
||||
from collections import Counter,deque
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
from .db import connect
|
||||
from .order_query import parse_query,filters,STAGE_LABELS
|
||||
from .order_tracking import current_stage
|
||||
|
||||
|
||||
def page_command(message):
|
||||
@@ -15,55 +19,246 @@ def page_command(message):
|
||||
return None
|
||||
|
||||
|
||||
def answer_list(message,tenants,conv_id=None):
|
||||
from .order_data import is_lookup_help,parse_count_query,tenant_filter,STATUS_LABELS
|
||||
from .customer_service import order_login_response
|
||||
def valid_filters(query):
|
||||
from .order_data import STATUS_LABELS
|
||||
return (isinstance(query,dict) and set(query)=={'status','stage','exception'}
|
||||
and (query['status'] is None or query['status'] in STATUS_LABELS)
|
||||
and (query['stage'] is None or query['stage'] in STAGE_LABELS)
|
||||
and isinstance(query['exception'],bool))
|
||||
|
||||
command=page_command(message)
|
||||
initial=(not is_lookup_help(message) and re.search(r'订单|运单|\borders?\b|\bshipments?\b',message,re.I)
|
||||
and re.search(r'列出|列表|全部|所有|\blist\b',message,re.I))
|
||||
if command is None and not initial:return None
|
||||
if not tenants:return order_login_response()
|
||||
page=1;status=None
|
||||
if command is None:
|
||||
intent=parse_count_query(re.sub(r'列出|列表|全部|所有|\blist\b','多少',message,flags=re.I))
|
||||
if intent is None or intent['unsupported']:
|
||||
return {'t':'暂不支持按这个条件列出订单。可查看全部订单或按履约中、已完成等业务状态查询。'}
|
||||
status=intent['status']
|
||||
scope=tenant_filter(tenant_ids=tenants)
|
||||
if scope is None:return order_login_response()
|
||||
connection=None
|
||||
|
||||
def load_state(conv_id):
|
||||
from .customer_service import current_customer_id,current_scope_key
|
||||
from .visitor_service import visitor_id
|
||||
if not conv_id:return None
|
||||
connection=connect()
|
||||
try:
|
||||
connection=connect()
|
||||
if command is not None:
|
||||
if not conv_id:return {'t':'请先在当前对话中发送“列出我的订单”,再翻页查看。'}
|
||||
prior=connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='assistant' ORDER BY id DESC LIMIT 1",(conv_id,)).fetchone()
|
||||
header=prior[0].split('\n',1)[0] if prior else ''
|
||||
match=re.fullmatch(r'您当前的订单列表(.*第(\d+)页;业务状态:([^)]+)):',header)
|
||||
if not match:return {'t':'请先在当前对话中发送“列出我的订单”,再翻页查看。'}
|
||||
labels={label:code for code,label in STATUS_LABELS.items()}
|
||||
status=labels.get(match[2])
|
||||
if match[2]!='全部' and status is None:return {'t':'请重新指定需要查看的订单状态。'}
|
||||
page=int(command) if isinstance(command,int) else max(1,int(match[1])+(1 if command=='next' else -1))
|
||||
if not 1<=page<=10000:return {'t':'请提供1至10000之间的有效页码。'}
|
||||
where=' WHERE 1=1'+scope[0];values=scope[1]
|
||||
if status:where+=' AND status=?';values=values+[status]
|
||||
# Both reads see one current snapshot and apply exactly the same scope/filter.
|
||||
row=connection.execute('SELECT order_list_state FROM chat_history WHERE id=? AND visitor_id=? '
|
||||
'AND customer_id=? AND customer_scope=?',
|
||||
(conv_id,visitor_id(),current_customer_id(),current_scope_key())).fetchone()
|
||||
state=json.loads(row[0]) if row and row[0] else None
|
||||
if not isinstance(state,dict) or not valid_filters(state.get('query')):return None
|
||||
if type(state.get('page')) is not int or not 1<=state['page']<=10000:return None
|
||||
if state.get('size') not in (5,20) or type(state.get('message_id')) is not int:return None
|
||||
for (text,) in connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' AND id>? ORDER BY id",
|
||||
(conv_id,state['message_id'])):
|
||||
if page_command(text) is None and parse_query(text) is None and text.strip() not in ('查看全部','查看全部订单'):
|
||||
return None
|
||||
return state
|
||||
except (ValueError,TypeError):return None
|
||||
finally:connection.close()
|
||||
|
||||
|
||||
def save_state(conv_id,state,message_id):
|
||||
"""Called only after the reply and customer binding have been persisted."""
|
||||
from .customer_service import current_customer_id,current_scope_key
|
||||
from .visitor_service import visitor_id
|
||||
if not conv_id or type(message_id) is not int:return
|
||||
connection=connect()
|
||||
try:
|
||||
state=dict(state)
|
||||
state['message_id']=message_id
|
||||
connection.execute('UPDATE chat_history SET order_list_state=? WHERE id=? AND visitor_id=? '
|
||||
'AND customer_id=? AND customer_scope=? '
|
||||
'AND NOT EXISTS(SELECT 1 FROM messages WHERE conv_id=? AND id>?)',
|
||||
(json.dumps(state,ensure_ascii=False),conv_id,visitor_id(),current_customer_id(),current_scope_key(),conv_id,message_id))
|
||||
connection.commit()
|
||||
finally:connection.close()
|
||||
|
||||
|
||||
def scoped_orders(connection,tenants,status=None):
|
||||
"""Use one authorized projection and normalization for lists and overviews."""
|
||||
from .order_data import tenant_filter
|
||||
scope=tenant_filter(tenant_ids=tenants)
|
||||
if scope is None:raise ValueError('Explicit customer scope required')
|
||||
where=' WHERE 1=1'+scope[0]
|
||||
values=scope[1]
|
||||
if status:
|
||||
where+=' AND status=?'
|
||||
values=values+[status]
|
||||
rows=connection.execute('SELECT order_no,inbound_code,status,warehouse_code,tracking_json,exception_reason '
|
||||
'FROM order_test_data'+where+' ORDER BY COALESCE(julianday(source_updated_at),julianday(snapshot_at),0) DESC,id DESC',values)
|
||||
for row in rows:
|
||||
item=dict(row)
|
||||
item['exception_reason']=(item['exception_reason'] or '').strip()
|
||||
item['stage']=current_stage(item.pop('tracking_json'))
|
||||
yield item
|
||||
|
||||
|
||||
def read_overview(tenants):
|
||||
"""Summarize the customer's current orders in one database snapshot."""
|
||||
connection=connect()
|
||||
try:
|
||||
connection.row_factory=sqlite3.Row
|
||||
connection.execute('BEGIN')
|
||||
total=connection.execute('SELECT COUNT(*) FROM order_test_data'+where,values).fetchone()[0]
|
||||
page=min(page,max(1,(total+19)//20))
|
||||
rows=connection.execute('SELECT order_no,inbound_code,status,warehouse_code FROM order_test_data'+where+
|
||||
' ORDER BY id DESC LIMIT 20 OFFSET ?',values+[(page-1)*20]).fetchall()
|
||||
if not rows:return {'t':'当前账号下没有符合条件的订单。','_customer_data':True}
|
||||
label=STATUS_LABELS[status] if status else '全部'
|
||||
first='先列出20票;' if page==1 and total>20 else ''
|
||||
lines=[f'您当前的订单列表({first}第{page}页;业务状态:{label}):',f'共{total}票,当前显示第{(page-1)*20+1}至{(page-1)*20+len(rows)}票。']
|
||||
for number,inbound,business_status,warehouse in rows:
|
||||
lines.append(f'- 入仓码:{inbound or "未登记"};订单号:{number};业务状态:{STATUS_LABELS.get(business_status,business_status)};目的仓:{warehouse or "未登记"}')
|
||||
lines.append('回复“下一页”查看后续订单。' if page*20<total else '当前已是最后一页。')
|
||||
lines.append('发送入仓码可查询该票订单和物流轨迹。')
|
||||
return {'t':'\n'.join(lines),'_customer_data':True}
|
||||
total=processing=exceptions=0
|
||||
stages=Counter()
|
||||
abnormal_rows=[]
|
||||
processing_rows=[]
|
||||
for row in scoped_orders(connection,tenants):
|
||||
total+=1
|
||||
abnormal=bool(row['exception_reason'])
|
||||
if abnormal:
|
||||
exceptions+=1
|
||||
if len(abnormal_rows)<5:abnormal_rows.append(row)
|
||||
if row['status']=='PROCESSING':
|
||||
processing+=1
|
||||
stages[row['stage']]+=1
|
||||
if not abnormal and len(processing_rows)<5:processing_rows.append(row)
|
||||
return {'total':total,'processing':processing,'exceptions':exceptions,
|
||||
'stages':[{'code':code,'count':stages[code]} for code in STAGE_LABELS if stages[code]],
|
||||
'orders':(abnormal_rows+processing_rows)[:5]}
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def answer_overview(message,tenants):
|
||||
question=re.sub(r'[\s,,。.!!??]','',message)
|
||||
chinese=re.fullmatch(r'(?:请|帮我|请帮我)?(?:看一下|看看|查看|查询|显示)?(?:一下)?'
|
||||
r'(?:(?:我|当前账号)的?|当前|现在)?(?:订单|运单)(?:概览|概况|总体情况|整体情况|情况|汇总)(?:呢|吧|吗)?',question)
|
||||
english=re.fullmatch(r'(?:please\s+)?(?:(?:show|give)\s+(?:me\s+)?)?(?:my\s+)?(?:order|shipment)s?\s+(?:overview|summary)',message.strip(' .!?'),re.I)
|
||||
if not chinese and not english:return None
|
||||
if not tenants:
|
||||
from .customer_service import order_login_response
|
||||
return order_login_response()
|
||||
try:
|
||||
data=read_overview(tenants)
|
||||
except sqlite3.Error:
|
||||
return {'t':'订单信息暂时无法读取,请稍后重试。'}
|
||||
finally:
|
||||
if connection is not None:connection.close()
|
||||
lines=[f'您当前共有 {data["total"]} 票订单,履约中 {data["processing"]} 票,异常订单 {data["exceptions"]} 票。']
|
||||
if data['stages']:
|
||||
lines.append('履约中订单的当前实际节点:')
|
||||
lines.extend(f'- {STAGE_LABELS[stage["code"]]}:{stage["count"]}票' for stage in data['stages'])
|
||||
if data['orders']:
|
||||
lines.append('重点订单(异常优先,最多5票):')
|
||||
lines.extend(row_text(order) for order in data['orders'])
|
||||
return {'t':'\n'.join(lines),'_customer_data':True}
|
||||
|
||||
|
||||
def matches_filters(row,query):
|
||||
return ((not query['status'] or row['status']==query['status'])
|
||||
and (not query['stage'] or row['stage']==query['stage'])
|
||||
and (not query['exception'] or bool(row['exception_reason'])))
|
||||
|
||||
|
||||
def read_orders(tenants,query,page=1,size=5,comparisons=()):
|
||||
"""One scoped snapshot; each order occupies one actual-node bucket."""
|
||||
if not valid_filters(query) or any(not valid_filters(part) for part in comparisons):
|
||||
raise ValueError('Explicit filters required')
|
||||
connection=connect()
|
||||
try:
|
||||
connection.row_factory=sqlite3.Row
|
||||
connection.execute('BEGIN')
|
||||
total=with_inbound=0;groups=Counter();selected=[];tail=deque(maxlen=size)
|
||||
comparison_groups=[Counter() for _ in comparisons]
|
||||
for item in scoped_orders(connection,tenants):
|
||||
for index,comparison in enumerate(comparisons):
|
||||
if matches_filters(item,comparison):comparison_groups[index][item['stage']]+=1
|
||||
if not matches_filters(item,query):continue
|
||||
total+=1;with_inbound+=bool((item['inbound_code'] or '').strip());groups[item['stage']]+=1
|
||||
tail.append(item)
|
||||
if (page-1)*size<total<=page*size:selected.append(item)
|
||||
last=max(1,(total+size-1)//size)
|
||||
if page>last:
|
||||
page=last;selected=list(tail)[-(total-(last-1)*size):] if total else []
|
||||
return {'total':total,'with_inbound':with_inbound,'groups':groups,'rows':selected,'page':page,
|
||||
'counts':[sum(group.values()) for group in comparison_groups],'comparison_groups':comparison_groups}
|
||||
finally:connection.close()
|
||||
|
||||
|
||||
def row_text(row):
|
||||
from .order_data import STATUS_LABELS
|
||||
def clean(value):return re.sub(r'[\r\n\t]+',' ',str(value or '未登记'))
|
||||
text=f'- 入仓码:{clean(row["inbound_code"])};订单号:{clean(row["order_no"])};业务状态:{STATUS_LABELS.get(row["status"],row["status"])};目的仓:{clean(row["warehouse_code"])};当前节点:{STAGE_LABELS[row["stage"]]}'
|
||||
if row['exception_reason']:text+=';异常原因:'+clean(row['exception_reason'])
|
||||
return text
|
||||
|
||||
|
||||
def unsupported(listing=False):
|
||||
return {'t':f'暂不支持按这个条件{"列出订单" if listing else "统计"}。可以查询订单总数、业务状态、明确标记的异常,或按当前实际物流节点统计;请一次指定一个节点。'}
|
||||
|
||||
|
||||
def summary_text(query,total):
|
||||
from .order_data import STATUS_LABELS
|
||||
if query['stage']:
|
||||
text=f'您当前有 {total} 票当前节点为“{STAGE_LABELS[query["stage"]]}”的订单'
|
||||
elif query['status']:
|
||||
text=f'您当前有 {total} 票业务状态为“{STATUS_LABELS[query["status"]]}”的订单'
|
||||
elif query['exception']:text=f'您当前有 {total} 票明确标记异常的订单'
|
||||
else:text=f'您当前共有 {total} 票订单'
|
||||
if query['exception'] and (query['stage'] or query['status']):text+='(已明确标记异常)'
|
||||
if query['stage'] and query['status']:text+=f'(业务状态为{STATUS_LABELS[query["status"]]})'
|
||||
return text
|
||||
|
||||
|
||||
def stage_distribution(groups):
|
||||
return ['按当前实际物流节点分布(每票只计一次):']+[
|
||||
f'- {label}:{groups[code]}票' for code,label in STAGE_LABELS.items() if groups[code]]
|
||||
|
||||
|
||||
def answer_summary(intent,tenants,conv_id=None):
|
||||
from .customer_service import order_login_response
|
||||
if not tenants:return order_login_response()
|
||||
if intent['unsupported']:return unsupported()
|
||||
requested=intent.get('summaries') or [intent]
|
||||
summaries=[part for part in requested if not part['unsupported']]
|
||||
if not summaries:return unsupported()
|
||||
query=filters(summaries[-1])
|
||||
comparisons=[filters(part) for part in summaries[:-1]]
|
||||
try:
|
||||
data=read_orders(tenants,query,comparisons=comparisons)
|
||||
total=data['total']
|
||||
lines=[]
|
||||
for part,count,groups in zip(summaries[:-1],data['counts'],data['comparison_groups']):
|
||||
lines.append(summary_text(part,count)+'。')
|
||||
if part.get('grouped'):lines.extend(stage_distribution(groups))
|
||||
lines.append(summary_text(query,total)+f',其中 {data["with_inbound"]} 票已登记入仓码。')
|
||||
if summaries[-1].get('grouped'):
|
||||
lines.extend(stage_distribution(data['groups']))
|
||||
for part in requested:
|
||||
if part['unsupported']:
|
||||
question=re.sub(r'\s+',' ',part['question'])[:120]
|
||||
lines.append(f'“{question}”的筛选条件尚未明确,请确认具体含义,可按业务状态、异常或当前实际节点查询。')
|
||||
if data['rows']:
|
||||
lines.append(f'最近更新的{len(data["rows"])}票:')
|
||||
lines.extend(row_text(row) for row in data['rows'])
|
||||
lines.append('回复“查看全部”展开列表,或发送入仓码查询详情。')
|
||||
state={'query':query,'page':1,'size':5}
|
||||
previous=load_state(conv_id)
|
||||
if previous and previous['query']==query:state={key:previous[key] for key in ('query','page','size')}
|
||||
return {'t':'\n'.join(lines),'_customer_data':True,'_order_list_state':state}
|
||||
except sqlite3.Error:return {'t':'订单信息暂时无法读取,请稍后重试。'}
|
||||
|
||||
|
||||
def answer_list(message,tenants,conv_id=None):
|
||||
from .order_data import is_lookup_help,STATUS_LABELS
|
||||
from .customer_service import order_login_response
|
||||
command=page_command(message)
|
||||
expand=message.strip(' ,,。.!!??') in ('查看全部','查看全部订单')
|
||||
initial=(not is_lookup_help(message) and re.search(r'订单|运单|\borders?\b|\bshipments?\b',message,re.I)
|
||||
and re.search(r'列出|列表|全部|所有|\blist\b',message,re.I))
|
||||
if command is None and not initial and not expand:return None
|
||||
if not tenants:return order_login_response()
|
||||
try:
|
||||
previous=load_state(conv_id)
|
||||
if command is not None or expand:
|
||||
if not previous:return {'t':'请先在当前对话中发送“列出我的订单”或查询订单数量,再翻页查看。'}
|
||||
query=previous['query'];size=20 if expand else previous['size']
|
||||
page=1 if expand else int(command) if isinstance(command,int) else max(1,previous['page']+(1 if command=='next' else -1))
|
||||
if not 1<=page<=10000:return {'t':'请提供1至10000之间的有效页码。'}
|
||||
else:
|
||||
intent=parse_query(message,listing=True)
|
||||
if intent is None or intent['unsupported']:return unsupported(True)
|
||||
query=filters(intent);page=1;size=20
|
||||
data=read_orders(tenants,query,page,size);page=data['page'];total=data['total']
|
||||
state={'query':query,'page':page,'size':size}
|
||||
if not total:return {'t':'当前账号下没有符合条件的订单。','_customer_data':True,'_order_list_state':state}
|
||||
label=STATUS_LABELS[query['status']] if query['status'] else '全部'
|
||||
first=f'先列出{size}票;' if page==1 and total>size else ''
|
||||
extra=(';当前节点:'+STAGE_LABELS[query['stage']] if query['stage'] else '')+(';明确标记异常' if query['exception'] else '')
|
||||
lines=[f'您当前的订单列表({first}第{page}页;业务状态:{label}{extra}):',f'共{total}票,当前显示第{(page-1)*size+1}至{(page-1)*size+len(data["rows"])}票。']
|
||||
lines.extend(row_text(row) for row in data['rows'])
|
||||
lines.append('回复“下一页”查看后续订单。' if page*size<total else '当前已是最后一页。')
|
||||
lines.append('发送入仓码可查询该票订单和物流轨迹。')
|
||||
return {'t':'\n'.join(lines),'_customer_data':True,'_order_list_state':state}
|
||||
except sqlite3.Error:return {'t':'订单信息暂时无法读取,请稍后重试。'}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Recognize explicit, whitelisted order summary and list filters."""
|
||||
|
||||
import re
|
||||
|
||||
from .order_tracking import NODE_LABELS
|
||||
|
||||
|
||||
NODE_PHRASES = (
|
||||
('DOMESTIC_RECEIVED',r'已入国内仓|已入仓'),
|
||||
('LOADING_COMPLETED',r'已完成装柜|已装柜'),
|
||||
('DEPARTED_WAREHOUSE',r'已离开国内仓库|已离仓|已出库'),
|
||||
('AT_ORIGIN_PORT',r'重箱已进入起运港或码头|已到起运港|已进起运港'),
|
||||
('LOADED_ON_VESSEL',r'已装船'),
|
||||
('DEPARTED_ORIGIN_PORT',r'船舶已离开起运港|已开船|开船'),
|
||||
('IN_TRANSIT',r'海运途中|运输中|在途|海上'),
|
||||
('ARRIVED_DESTINATION_PORT',r'船舶已到目的港|已到目的港|已到港|到港'),
|
||||
('DISCHARGED_FROM_VESSEL',r'已卸船、未清关|卸船未清关|已卸船|卸船'),
|
||||
('PICKUP_READY',r'已清关、可提柜|已清关|清关完成|可提柜'),
|
||||
('PICKED_UP',r'已提柜|提柜'),
|
||||
('EMPTY_RETURNED',r'已还柜|还柜'),
|
||||
('NO_ACTUAL',r'没有实际轨迹|暂无实际节点|没有轨迹|无轨迹'),
|
||||
('MIXED',r'多柜进度不一致|多柜进度不同'),
|
||||
)
|
||||
STAGE_LABELS = {code:labels[1] for code,labels in NODE_LABELS.items()}
|
||||
STAGE_LABELS.update(NO_ACTUAL='暂无实际节点',MIXED='多柜进度不同')
|
||||
STATUSES = (('头程订单已完成','COMPLETED'),('已完成','COMPLETED'),('履约中','PROCESSING'),
|
||||
('处理中','PROCESSING'),('已取消','CANCELLED'),('审核通过','APPROVED'),
|
||||
('审核驳回','REJECTED'),('已接单','ACCEPTED'),('已提交','SUBMITTED'),('草稿','DRAFT'),
|
||||
('processing','PROCESSING'))
|
||||
NOUN = r'订单|运单|入仓码|入仓号|\borders?\b|\bshipments?\b'
|
||||
COUNT = r'多少|几(?:个|票|条|张|笔)|总数|数量|总量|统计|数一下|(?:订单|运单)数(?!量)|\bhow\s+many\b|\bcount\b|\bnumber\b|\btotal\b'
|
||||
|
||||
|
||||
def parse_query(message,listing=False):
|
||||
message=re.sub(r'^(?:(?:你好|您好)[,,!!。\s]*)+','',message.strip())
|
||||
message=re.sub(r'^(?:或者|那么|那)[,,\s]*','',message)
|
||||
message=re.sub(r'^(?:当前|目前|现在)[,,\s]+','',message)
|
||||
has_noun=bool(re.search(NOUN,message,re.I))
|
||||
condition=r'异常|\b(?:exception|abnormal)\b|'+'|'.join(pattern for _,pattern in NODE_PHRASES)+'|'+'|'.join(re.escape(phrase) for phrase,_ in STATUSES)
|
||||
if not has_noun and (listing or not re.search(COUNT,message,re.I) or not re.search(condition,message,re.I)):
|
||||
return None
|
||||
if not listing and not (re.search(COUNT,message,re.I) or re.search(r'分布|(?:都|分别).*到哪',message)):return None
|
||||
if re.search(r'公斤|千克|\bkg\b|重量|毛重|体积|件数|箱数|运费|费用|价格|多少钱',message,re.I):return None
|
||||
if not listing:
|
||||
parts=[part.strip() for part in re.split(r'[,,。;;??]+|以及|还有|并且|和|\band\b',message,flags=re.I) if part.strip()]
|
||||
if len(parts)>1 and all(re.search(COUNT,part,re.I) for part in parts):
|
||||
queries=[parse_query(part if re.search(NOUN,part,re.I) else part+'订单') for part in parts[:5]]
|
||||
for question,query in zip(parts,queries):query['question']=question
|
||||
return {'summaries':queries,'unsupported':len(parts)>5}
|
||||
remaining=message
|
||||
statuses=[];nodes=[]
|
||||
# Unknown/negated logistics conditions must never silently become a total.
|
||||
unsupported=bool(re.search(r'未到港|没到港|还没|未开船|未发货|未签收|已签收|已送达|曾经|曾到|到过|正常订单|无异常',remaining))
|
||||
for code,pattern in NODE_PHRASES:
|
||||
if re.search(pattern,remaining):
|
||||
nodes.append(code);remaining=re.sub(pattern,'',remaining)
|
||||
for phrase,status in STATUSES:
|
||||
if re.search(re.escape(phrase),remaining,re.I):
|
||||
statuses.append(status);remaining=re.sub(re.escape(phrase),'',remaining,flags=re.I)
|
||||
exception=bool(re.search(r'异常|\b(?:exception|abnormal)\b',remaining,re.I))
|
||||
remaining=re.sub(r'物流异常|异常|\b(?:exception|abnormal)\b','',remaining,flags=re.I)
|
||||
grouped=not nodes and bool(re.search(r'轨迹|节点|阶段|到哪|在哪|\btracking (?:stages?|nodes?)\b',remaining,re.I))
|
||||
remaining=re.sub(r'物流轨迹|物流节点|当前节点|轨迹|节点|阶段|到哪里|到哪|在哪里|在哪儿|在哪|各自|各个|每个|分别|分布|各|都|了|\btracking (?:stages?|nodes?)\b', '',remaining,flags=re.I)
|
||||
remaining=re.sub(COUNT+'|'+NOUN,'',remaining,flags=re.I)
|
||||
remaining=re.sub(r'其中|当前账号|当前账户|账号下|账户下|名下|可查询|能查到|可以查到|目前|现在|当前|一共|总共|合计|总计|'
|
||||
r'请问|麻烦|帮我|想知道|想查|想看|请|查询|查一下|查下|查查|看一下|看看|看下|一下|下|列出|列表|显示|全部|所有|共有|我的|我|能|有|的|个|票|条|张|笔|呢|吗|呀|啊|共|'
|
||||
r'\b(?:list|what|is|of|do|i|have|are|there|my|in|all|the|currently|at|each)\b|[\s,,。.!!??、::]','',remaining,flags=re.I)
|
||||
return {'status':statuses[0] if statuses else None,'stage':nodes[0] if nodes else None,
|
||||
'exception':exception,'grouped':grouped,
|
||||
'unsupported':bool(unsupported or remaining or len(set(statuses))>1 or len(set(nodes))>1)}
|
||||
|
||||
|
||||
def filters(intent):
|
||||
return {key:intent.get(key) for key in ('status','stage','exception')}
|
||||
@@ -33,14 +33,14 @@ def parse_time(value):
|
||||
return None
|
||||
|
||||
|
||||
def build_tracking(raw):
|
||||
"""Latest observation corrects a milestone; forecasts never advance progress."""
|
||||
def tracking_facts(raw):
|
||||
"""Share the same corrections and actual/estimated split with aggregation."""
|
||||
try:
|
||||
facts = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return {},{}
|
||||
if not isinstance(facts, list):
|
||||
return None
|
||||
return {},{}
|
||||
actual, estimated = {}, {}
|
||||
for index, fact in enumerate(facts):
|
||||
if not isinstance(fact, dict) or not isinstance(fact.get('code'), str) or fact['code'] not in NODE_LABELS:
|
||||
@@ -55,8 +55,27 @@ def build_tracking(raw):
|
||||
candidate = (observation, index, when, fact)
|
||||
if key not in target or candidate[:2] > target[key][:2]:
|
||||
target[key] = candidate
|
||||
if not actual and not estimated:
|
||||
return None
|
||||
return actual,estimated
|
||||
|
||||
|
||||
def current_stage(raw):
|
||||
actual,estimated=tracking_facts(raw)
|
||||
progress={}
|
||||
for (shipment,code) in actual:
|
||||
if shipment not in progress or NODE_LABELS[code][0]>NODE_LABELS[progress[shipment]][0]:
|
||||
progress[shipment]=code
|
||||
shipments={shipment for shipment,_ in actual.keys() | estimated.keys() if shipment}
|
||||
if shipments:
|
||||
if not any(shipment in progress for shipment in shipments) and '' in progress:return progress['']
|
||||
progress={shipment:progress.get(shipment,'NO_ACTUAL') for shipment in shipments}
|
||||
codes=set(progress.values())
|
||||
return next(iter(codes)) if len(codes)==1 else 'MIXED' if codes else 'NO_ACTUAL'
|
||||
|
||||
|
||||
def build_tracking(raw):
|
||||
"""Latest observation corrects a milestone; forecasts never advance progress."""
|
||||
actual,estimated=tracking_facts(raw)
|
||||
if not actual and not estimated:return None
|
||||
|
||||
shipments = sorted({key[0] for key in actual.keys() | estimated.keys()} - {''})
|
||||
multiple = len(shipments) > 1
|
||||
|
||||
+31
-17
@@ -94,15 +94,36 @@ RATES = {
|
||||
|
||||
CBM_CHANNELS = ("卡航", "普卡", "铁路", "海运")
|
||||
|
||||
def parse_dims(t):
|
||||
"""解析长x宽x高,支持小数、空格及 x/×/* 分隔。
|
||||
DIMENSION_NUMBER = r'(?<![\d.+-])(\d+(?:\.\d+)?)(?![\d.])'
|
||||
DIMENSION_UNIT = r'\s*(英寸|毫米|厘米|公分|米|(?:inches|inch|in|cm|mm|m)(?=$|[^A-Za-z0-9²³]|[xX])|["″])?\s*'
|
||||
DIMENSION_AXIS = DIMENSION_NUMBER + DIMENSION_UNIT
|
||||
DIMENSION_PATTERNS = tuple(re.compile(pattern,re.I) for pattern in (
|
||||
DIMENSION_AXIS+r'[*xX×]\s*'+DIMENSION_AXIS+r'[*xX×]\s*'+DIMENSION_AXIS,
|
||||
r'长\s*'+DIMENSION_AXIS+r'宽\s*'+DIMENSION_AXIS+r'高\s*'+DIMENSION_AXIS,
|
||||
DIMENSION_AXIS+r'长\s*'+DIMENSION_AXIS+r'宽\s*'+DIMENSION_AXIS+r'高',
|
||||
))
|
||||
|
||||
用"最长连续数字"匹配三组,要求每组前后都不与其它数字相连,
|
||||
这样 "100x80x60 200kg" 里的 60 不会被后面的 200 吞成 6020。
|
||||
"""
|
||||
match = re.search(r'(?<![\d.+-])(\d+(?:\.\d+)?)\s*[*xX×]\s*'
|
||||
r'(\d+(?:\.\d+)?)\s*[*xX×]\s*(\d+(?:\.\d+)?)(?![\d.])', t)
|
||||
return [float(value) for value in match.groups()] if match else None
|
||||
|
||||
def dimension_measurement(text):
|
||||
"""Keep units attached to the three packaging axes, not product descriptions."""
|
||||
matches=[match for pattern in DIMENSION_PATTERNS if (match:=pattern.search(text))]
|
||||
if not matches:return None
|
||||
match=min(matches,key=lambda item:item.start())
|
||||
parts=match.groups()
|
||||
values=[float(parts[index]) for index in (0,2,4)]
|
||||
aliases={'英寸':'in','inches':'in','inch':'in','"':'in','″':'in',
|
||||
'毫米':'mm','厘米':'cm','公分':'cm','米':'m'}
|
||||
units=[aliases.get(value.lower(),value.lower()) if value else None for value in parts[1::2]]
|
||||
default=next((unit for unit in reversed(units) if unit),'cm')
|
||||
units=[unit or default for unit in units]
|
||||
if len(set(units))==1:return values,units[0]
|
||||
factors={'cm':1,'mm':0.1,'m':100,'in':2.54}
|
||||
return [value*factors[unit] for value,unit in zip(values,units)],'cm'
|
||||
|
||||
|
||||
def parse_dims(t):
|
||||
measurement=dimension_measurement(t)
|
||||
return measurement[0] if measurement else None
|
||||
|
||||
|
||||
SHIPPING_REGION_PATTERNS = tuple((re.compile(pattern, re.I), region) for region, pattern in (
|
||||
@@ -142,15 +163,6 @@ def parse_shipping_intent(text):
|
||||
|
||||
# 尺寸:在原始文本上解析(空格有助于区分尺寸和后面的重量)
|
||||
intent["dims"] = parse_dims(text)
|
||||
if not intent["dims"]:
|
||||
# 支持“长100宽80高60”
|
||||
dl = re.search(r"长(\d+(?:\.\d+)?)宽(\d+(?:\.\d+)?)高(\d+(?:\.\d+)?)", t)
|
||||
if dl:
|
||||
intent["dims"] = [float(x) for x in dl.groups()]
|
||||
else:
|
||||
dl = re.search(r"(\d+(?:\.\d+)?)长(\d+(?:\.\d+)?)宽(\d+(?:\.\d+)?)高", t)
|
||||
if dl:
|
||||
intent["dims"] = [float(x) for x in dl.groups()]
|
||||
|
||||
# 重量:带单位的数字。去掉空格后在原始文本上匹配,
|
||||
# 但不能把尺寸里的数字(如 120*100*100 的第三个数)当成重量
|
||||
@@ -182,6 +194,8 @@ def parse_shipping_intent(text):
|
||||
|
||||
def detect_dim_unit(text):
|
||||
"""Recognize explicit dimensions; aggregate CBM does not imply meters."""
|
||||
measurement=dimension_measurement(text)
|
||||
if measurement:return measurement[1]
|
||||
low = text.lower()
|
||||
if re.search(r'英寸|\binch(?:es)?(?![A-Za-z])|\d\s*in(?![A-Za-z0-9])|\d\s*["″]',low):
|
||||
return 'in'
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from copy import deepcopy
|
||||
from decimal import Decimal
|
||||
import re
|
||||
|
||||
|
||||
QUANTITY_QUESTION='箱数已更改,请确认新的总重或各箱重量,原分箱明细不能直接套用。'
|
||||
@@ -9,7 +10,13 @@ QUANTITY_QUESTION='箱数已更改,请确认新的总重或各箱重量,原
|
||||
|
||||
def merge_intent(current,previous):
|
||||
result=deepcopy(current)
|
||||
excluded={'mode','text','errors','dim_unit','quantity_note','quantity_uncertain','preserve_total'}
|
||||
if (current['mode']=='quote' and previous['mode'] in ('cheapest','fastest','slowest','speed_compare')
|
||||
and not re.search(r'全部|所有|各(?:个)?渠道',current['text'])):
|
||||
result['mode']=previous['mode']
|
||||
excluded={'mode','text','errors','dim_unit','quantity_note','quantity_uncertain','preserve_total','metal_weight_update'}
|
||||
if current.get('metal_weight_update') and previous.get('metal_chargeable_weight') is None:
|
||||
result['metal_chargeable_weight']=None
|
||||
result['errors'].append('请确认铁铝部分的计费重,不能把毛重直接当作计费重。')
|
||||
if current.get('warehouse') or current.get('unknown_warehouses'):
|
||||
excluded.update(('postal','zone','zones'))
|
||||
elif current.get('postal'):
|
||||
|
||||
@@ -127,6 +127,24 @@ def quantity_number(value,intent):
|
||||
return '约'+number(value,6)
|
||||
|
||||
|
||||
def select_timed_rates(rates,slowest=False):
|
||||
"""Compare numeric day ranges within one region, service kind and billing basis."""
|
||||
ranked=[]
|
||||
best={}
|
||||
for rate in rates:
|
||||
match=re.search(r'(?<![\d.])(\d+(?:\.\d+)?)\s*(?:[-—–~~至]\s*(\d+(?:\.\d+)?))?\s*(?:天|days?)',rate['eta'],re.I)
|
||||
if not match:continue
|
||||
low=Decimal(match[1])
|
||||
high=Decimal(match[2] or match[1])
|
||||
if low<=0 or high<low:continue
|
||||
key=(rate['zone'],rate['kind'],rate['unit'],rate['tax'])
|
||||
timing=(high,low) if slowest else (low,high)
|
||||
ranked.append((rate,key,timing))
|
||||
choose=max if slowest else min
|
||||
best[key]=choose(best.get(key,timing),timing)
|
||||
return [rate for rate,key,timing in ranked if timing==best[key]]
|
||||
|
||||
|
||||
def render_rates(intent, book, destinations, remote_fees):
|
||||
from .quote_fees import charge_details
|
||||
from .quote_language import translate_quote
|
||||
@@ -154,35 +172,42 @@ def render_rates(intent, book, destinations, remote_fees):
|
||||
if part and intent.get('metal_chargeable_weight') is not None:
|
||||
billing.update(metal_chargeable_weight=None,metal_scope='partial')
|
||||
rates = match_rates(billing, book, destination)
|
||||
if intent.get('mode') in ('cheapest','fastest') and rates:
|
||||
if intent['mode']=='cheapest':
|
||||
eligible=[r for r in rates if quantity(billing,r) is not None]
|
||||
best={}
|
||||
for rate in eligible:
|
||||
key=(destination['zone'] or rate['zone'],rate['unit'],rate['tax'])
|
||||
value=quantity(billing,rate)*Decimal(rate['price'])+charge_details(billing,rate,fees,book)['cny']
|
||||
best[key]=min(best.get(key,value),value)
|
||||
rates=[r for r in eligible if quantity(billing,r)*Decimal(r['price'])+charge_details(billing,r,fees,book)['cny']==best[(destination['zone'] or r['zone'],r['unit'],r['tax'])]] or rates
|
||||
lines.append('按相同区域、计费单位及税费方式比较的较低价方案:')
|
||||
else:
|
||||
def eta_key(rate):
|
||||
values=[Decimal(v) for v in re.findall(r'\d+(?:\.\d+)?',rate['eta'])]
|
||||
return tuple(values[:2]) if values else (Decimal('Infinity'),)
|
||||
earliest=min(eta_key(r) for r in rates)
|
||||
rates=[r for r in rates if eta_key(r)==earliest]
|
||||
lines.append('按资料中的参考时效,较快方案如下;提取不等于送达或签收:')
|
||||
if intent.get('mode')=='cheapest' and rates:
|
||||
eligible=[r for r in rates if quantity(billing,r) is not None]
|
||||
best={}
|
||||
for rate in eligible:
|
||||
key=(destination['zone'] or rate['zone'],rate['unit'],rate['tax'])
|
||||
value=quantity(billing,rate)*Decimal(rate['price'])+charge_details(billing,rate,fees,book)['cny']
|
||||
best[key]=min(best.get(key,value),value)
|
||||
rates=[r for r in eligible if quantity(billing,r)*Decimal(r['price'])+charge_details(billing,r,fees,book)['cny']==best[(destination['zone'] or r['zone'],r['unit'],r['tax'])]] or rates
|
||||
lines.append('按相同区域、计费单位及税费方式比较的较低价方案:')
|
||||
if not rates:
|
||||
lines.append(f"{destination['label']}:当前资料没有匹配的线路价格,提供目的仓码或邮编可继续查询。")
|
||||
continue
|
||||
if destination['label'] != '美国':
|
||||
lines.append(destination['label']+':')
|
||||
selections=[('',rates)]
|
||||
if intent.get('mode') in ('fastest','slowest','speed_compare'):
|
||||
selections=[]
|
||||
if intent['mode'] in ('fastest','speed_compare'):
|
||||
selections.append(('最快参考方案',select_timed_rates(rates)))
|
||||
if intent['mode'] in ('slowest','speed_compare'):
|
||||
selections.append(('最慢参考方案',select_timed_rates(rates,slowest=True)))
|
||||
lines.append('按相同区域、运输方式、计费单位及税费方式比较参考时效。最快按天数下限、最慢按天数上限选择。')
|
||||
grouped = {}
|
||||
for rate in rates:
|
||||
key = (rate['service'], rate['origin'], rate['unit'], rate['tax'])
|
||||
grouped.setdefault(key, []).append(rate)
|
||||
for (service, origin, unit, tax), rows in grouped.items():
|
||||
for selection,selected_rates in selections:
|
||||
if selection and not selected_rates:
|
||||
lines.append(selection+'暂时无法确定,匹配线路未列明可比较的天数。')
|
||||
for rate in selected_rates:
|
||||
key = (selection, rate['service'], rate['origin'], rate['unit'], rate['tax'], rate['eta'])
|
||||
grouped.setdefault(key, []).append(rate)
|
||||
last_selection=None
|
||||
for (selection,service, origin, unit, tax,eta_source), rows in grouped.items():
|
||||
if selection and selection!=last_selection:
|
||||
lines.append(selection)
|
||||
last_selection=selection
|
||||
if intent.get('mode')=='time':
|
||||
eta=rows[0]['eta'] or '当前资料未列明时效'
|
||||
eta=eta_source or '当前资料未列明时效'
|
||||
if rows[0]['kind']=='parcel' and '提取' not in eta:eta+='提取'
|
||||
lines.append(f'- {service} · {origin}:{eta}。提取不等于送达或签收。')
|
||||
continue
|
||||
@@ -203,7 +228,7 @@ def render_rates(intent, book, destinations, remote_fees):
|
||||
elif intent.get('cbm') and rate['unit']=='cbm':
|
||||
lines.append('计费体积四舍五入后不足0.01CBM,最低计费体积需人工确认,不能按零费用出价。')
|
||||
parts.append(rate_text)
|
||||
eta = re.sub(r'\s*无时效赔偿', '', rows[0]['eta']).strip()
|
||||
eta = re.sub(r'\s*无时效赔偿', '', eta_source).strip()
|
||||
if rows[0]['kind']=='parcel' and eta and '提取' not in eta:eta+='提取'
|
||||
elif rows[0]['kind']=='truck' and eta and '船开' not in eta:eta='开船后参考'+eta
|
||||
lines.append(f"- {service} · {origin} · {'包税' if tax=='included' else '不包税'}:"+';'.join(parts)
|
||||
|
||||
@@ -60,7 +60,11 @@ def request_mode(text):
|
||||
if preparation_topic(text):return 'preparation'
|
||||
if re.search(r'最便宜|便宜的|便宜一?点|优惠|折扣|打折|最省钱|最划算|最低价|cheapest|least expensive',text,re.I):return 'cheapest'
|
||||
if re.search(r'最终总价|一共多少钱|总共多少钱|合计多少钱|最终价格|最终费用|到门总价',text):return 'total'
|
||||
if re.search(r'最快|快的|fastest',text,re.I):return 'fastest'
|
||||
fastest=bool(re.search(r'最快|快的|fastest',text,re.I))
|
||||
slowest=bool(re.search(r'最慢|慢的|slowest',text,re.I))
|
||||
if fastest and slowest:return 'speed_compare'
|
||||
if fastest:return 'fastest'
|
||||
if slowest:return 'slowest'
|
||||
if re.search(r'一件代发|散箱入库|整柜卸货|卸货费|仓租|仓储|贴标费',text):return 'fulfillment'
|
||||
if re.search(r'FDA|申报费|报关|续页|品名',text,re.I):return 'customs'
|
||||
if re.search(r'附加费|含税|含派送|全包|包不包|还.*(?:费|加钱)|(?:额外|其他|其它).*(?:费用|收费)|包含.*费|包税.*(?:包含|包括)|超重费|超尺寸|住宅|私人|商业地址',text):return 'fees'
|
||||
@@ -164,6 +168,7 @@ def parse_intent(message,warehouse_codes):
|
||||
'errors':errors,'mode':request_mode(text),'english':True if english else (False if re.search(r'[\u4e00-\u9fff]',message) else None),
|
||||
'material':materials['material'],'metal_scope':materials['metal_scope'],
|
||||
'metal_chargeable_weight':materials['metal_chargeable_weight'],
|
||||
'metal_weight_update':materials['metal_weight_update'],
|
||||
'address_type':('non_fba' if re.search(r'私人|住宅|商业地址|非亚马逊',text) else ('fba' if warehouses else None)),
|
||||
'preserve_total':True if re.search(r'总重不变|重量不变',text) else None,'text':text}
|
||||
|
||||
@@ -177,6 +182,8 @@ def is_followup(message,intent):
|
||||
if intent.get('warehouse') or intent.get('unknown_warehouses'):
|
||||
return not (intent['country'] and intent['weight'])
|
||||
if intent.get('mode')!='quote':return not (intent['country'] and intent['weight'])
|
||||
if re.fullmatch(r'(?:请|请问|帮我|给我|请给我|麻烦)?(?:再|继续)?(?:提供|显示|列出|看一下|看看|查询)?'
|
||||
r'(?:具体|详细|全部渠道|所有渠道|各渠道)?(?:报价|价格|运费|费用)(?:呢|是多少|多少|怎么样|如何|多少钱)?',text):return True
|
||||
if re.fullmatch(r'(?:邮编|zip|postcode)?\s*\d{5}(?:-\d{4})?(?:呢)?',text,re.I):return True
|
||||
if re.fullmatch(r'(?:义乌|华东|深圳|华南|美国|美西|美中|美东|东[12]区|西[12]区|中[12]区|不包税|包税)(?:交仓|仓)?(?:呢)?',text):return True
|
||||
return bool(len(text)<=60 and not (intent['country'] and intent['weight']) and any(intent.get(k) is not None for k in ('services','kinds','transport','weight','dims','cbm','tax','origin','zone','material','metal_scope','metal_chargeable_weight')))
|
||||
|
||||
@@ -18,6 +18,9 @@ def translate_quote(text):
|
||||
('当前资料没有美国空运费率,下面列出海运方案。','The current rate sheet has no US air rates. Sea options are listed below.'),
|
||||
('以下按各目的仓分别比较;实际分票请提供每个仓的重量。','Each destination is compared separately; provide the weight assigned to each shipment.'),
|
||||
('按相同区域、计费单位及税费方式比较的较低价方案:','Lower-priced options within the same region, billing unit and tax basis:'),
|
||||
('按相同区域、运输方式、计费单位及税费方式比较参考时效。最快按天数下限、最慢按天数上限选择。','Reference times are compared within the same region, service kind, billing unit and tax basis. Fastest uses the lower day bound and slowest uses the upper day bound.'),
|
||||
('暂时无法确定,匹配线路未列明可比较的天数。',' cannot be determined because matching routes have no comparable numeric day estimates.'),
|
||||
('最快参考方案','Fastest reference options'),('最慢参考方案','Slowest reference options'),
|
||||
('按资料中的参考时效,较快方案如下;提取不等于送达或签收:','Faster reference options follow. Pickup timing is not a delivery or receipt commitment:'),
|
||||
('该邮编属于遥远地址,派送附加费需单独确认。','This ZIP code is extremely remote; the delivery surcharge requires confirmation.'),
|
||||
('海派已计入偏远附加费','Sea parcel totals include the remote surcharge of '),
|
||||
|
||||
@@ -16,15 +16,18 @@ def material_intent(text,weight_pattern,to_kg):
|
||||
else 'ordinary' if ordinary or re.search(r'塑料|木制',text) else None)
|
||||
name=r'(?:铁铝|铁制品|钢制品|铝制品|铁件|钢件|铝件|金属)(?:部分|货物)?(?:的)?'
|
||||
patterns=(name+r'\s*计费重(?:量)?\s*(?:为|是|[::])?\s*'+weight_pattern.pattern,
|
||||
name+r'\s*按\s*'+weight_pattern.pattern+r'\s*计费')
|
||||
charged=None;cleaned=text;errors=[]
|
||||
for pattern in patterns:
|
||||
name+r'\s*按\s*'+weight_pattern.pattern+r'\s*计费',
|
||||
r'(?:铁铝|铁制品|钢制品|铝制品|铁件|钢件|铝件|金属)部分(?:的计费重(?:量)?)?\s*'
|
||||
r'(?:还是|仍然是|仍是|改为|改成|换成)\s*'+weight_pattern.pattern)
|
||||
charged=None;cleaned=text;errors=[];oral=False
|
||||
for index,pattern in enumerate(patterns):
|
||||
matches=list(re.finditer(pattern,text,re.I))
|
||||
for match in matches:
|
||||
value=to_kg(*match.groups())
|
||||
if charged is not None and Decimal(charged)!=value:errors.append('铁铝货物的计费重不一致,请确认。')
|
||||
charged=str(value);scope='partial'
|
||||
oral=oral or (index==2 and '计费重' not in match[0])
|
||||
if not 0<value<=1000000:errors.append('请提供大于0的有效铁铝货物计费重。')
|
||||
cleaned=cleaned[:match.start()]+' '*(match.end()-match.start())+cleaned[match.end():]
|
||||
return {'material':material,'metal_scope':scope,'metal_chargeable_weight':charged,
|
||||
'quantity_text':cleaned,'errors':errors}
|
||||
'quantity_text':cleaned,'errors':errors,'metal_weight_update':True if oral else None}
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import re
|
||||
|
||||
from .db import connect
|
||||
from .quote_intent import US,QUOTE_WORDS,parse_intent,is_followup
|
||||
from .quote_intent import US
|
||||
from .pricing import shipping_region
|
||||
|
||||
|
||||
@@ -15,17 +14,8 @@ def answer_us_fallback(message,conv_id):
|
||||
active=bool(US.search(current))
|
||||
if not active and shipping_region(current) is not None:return None
|
||||
if not active and conv_id:
|
||||
connection=connect()
|
||||
try:
|
||||
recent=connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 10",(conv_id,)).fetchall()
|
||||
finally:connection.close()
|
||||
for (previous,) in recent:
|
||||
previous=clean_conditions(previous)
|
||||
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收|换个话题',previous):break
|
||||
if US.search(previous):active=True;break
|
||||
if shipping_region(previous) is not None:break
|
||||
parsed=parse_intent(previous,set())
|
||||
if not (QUOTE_WORDS.search(previous) or is_followup(previous,parsed)):break
|
||||
from .quote_service import previous_quote_intents
|
||||
active=any(intent['country']=='US' for intent in previous_quote_intents(conv_id,set()))
|
||||
if not active:return None
|
||||
from .quote_data import load_book
|
||||
if not load_book():return {'t':'当前尚未包含可用的美国报价表,请提供目的地仓码或邮编及货物信息,联系人工确认。'}
|
||||
|
||||
@@ -9,29 +9,39 @@ from .quote_data import load_book, postcode_fee
|
||||
from .quote_engine import US, QUOTE_WORDS, parse_intent, is_followup, variants, render_rates
|
||||
|
||||
|
||||
def previous_quote_intents(conv_id,codes):
|
||||
"""Read the current quote back to its topic boundary, without a turn cutoff."""
|
||||
earlier_intents=[]
|
||||
if conv_id:
|
||||
connection=connect()
|
||||
try:
|
||||
recent=connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC",(conv_id,))
|
||||
for (previous,) in recent:
|
||||
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收|换个话题|另外问',previous):break
|
||||
earlier=parse_intent(previous,codes)
|
||||
if earlier['country']=='other':break
|
||||
if not (QUOTE_WORDS.search(previous) or US.search(previous) or is_followup(previous,earlier) or
|
||||
any(earlier.get(key) for key in ('weight','dims','cbm','warehouse','postal'))):break
|
||||
earlier_intents.append(earlier)
|
||||
if not is_followup(previous,earlier):break
|
||||
finally:
|
||||
connection.close()
|
||||
return earlier_intents
|
||||
|
||||
|
||||
def intent_with_context(message, conv_id, book):
|
||||
from .quote_context import merge_intent
|
||||
|
||||
codes = {row['code'] for row in book['warehouses']}
|
||||
codes.update(value.upper() for rate in book['rates'] for value in rate['destinations'])
|
||||
intent = parse_intent(message,codes)
|
||||
earlier_intents=[]
|
||||
if conv_id and is_followup(message,intent):
|
||||
connection=connect()
|
||||
try:
|
||||
recent=connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 10",(conv_id,)).fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
for (previous,) in recent:
|
||||
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收',previous):break
|
||||
earlier=parse_intent(previous,codes)
|
||||
if earlier['country']=='other':break
|
||||
if not (QUOTE_WORDS.search(previous) or US.search(previous) or is_followup(previous,earlier) or
|
||||
any(earlier.get(key) for key in ('weight','dims','cbm','warehouse','postal'))):break
|
||||
earlier_intents.append(earlier)
|
||||
if not is_followup(previous,earlier):break
|
||||
earlier_intents=previous_quote_intents(conv_id,codes) if is_followup(message,intent) else []
|
||||
if intent.get('metal_weight_update') and not earlier_intents:
|
||||
intent['metal_chargeable_weight']=None
|
||||
intent['errors'].append('请确认铁铝部分的计费重,不能把毛重直接当作计费重。')
|
||||
if earlier_intents:
|
||||
state=earlier_intents[-1]
|
||||
if state.get('metal_weight_update'):state['metal_chargeable_weight']=None
|
||||
for update in list(reversed(earlier_intents[:-1]))+[intent]:
|
||||
state=merge_intent(update,state)
|
||||
intent=state
|
||||
|
||||
+21
-1
@@ -97,7 +97,27 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.recent-item > span:first-child { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.recent-item:hover { color: var(--brand-text); }
|
||||
.recent-date { white-space: nowrap; color: var(--muted); font-size: 10px; }
|
||||
.composer { flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); }
|
||||
.composer { position: relative; flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); }
|
||||
.voice-feedback {
|
||||
position: absolute;
|
||||
z-index: 36;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow);
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.voice-feedback.is-error { border-color: var(--danger); color: var(--danger); }
|
||||
.composer-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; }
|
||||
.composer-toolbar .hot-bar { flex: 1; min-width: 0; margin-bottom: 0; }
|
||||
.voice-toggle, .voice-hold { display: none; }
|
||||
|
||||
+36
-14
@@ -6,6 +6,8 @@ const sendBtn = document.getElementById("send-button");
|
||||
const uploadBtn = document.getElementById("upload-btn");
|
||||
const uploadInput = document.getElementById("upload-input");
|
||||
const composerStatus = document.getElementById("composer-status");
|
||||
const voiceFeedback = document.getElementById('voice-feedback');
|
||||
let voiceFeedbackTimer = null;
|
||||
const drafts = ChatDrafts.create({scope:VisitorSession.localKey(),storage:()=>localStorage,tabStorage:()=>sessionStorage});
|
||||
let draftTimer = null;
|
||||
let draftSnapshot = null;
|
||||
@@ -65,6 +67,21 @@ function setComposerStatus(message, isError){
|
||||
composerStatus.classList.toggle("is-error", Boolean(isError));
|
||||
}
|
||||
|
||||
function setVoiceStatus(message,isError){
|
||||
if(voiceFeedbackTimer!==null) clearTimeout(voiceFeedbackTimer);
|
||||
voiceFeedbackTimer=null;
|
||||
if(voiceFeedback.textContent!==message) voiceFeedback.textContent=message || '';
|
||||
voiceFeedback.hidden=!message;
|
||||
voiceFeedback.classList.toggle('is-error',Boolean(isError));
|
||||
if(message){
|
||||
voiceFeedbackTimer=setTimeout(function(){
|
||||
voiceFeedback.hidden=true;
|
||||
voiceFeedback.textContent='';
|
||||
voiceFeedbackTimer=null;
|
||||
},4000);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJson(path, options){
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(function(){ controller.abort(); }, 10000);
|
||||
@@ -290,11 +307,12 @@ const I18N = {
|
||||
retryAIOnly: "请先返回 AI 问答,再重答这条消息。",
|
||||
clickRetry: "点击重试",
|
||||
welcomeTitle: "物流问题,随时为您解答",
|
||||
welcomeEyebrow: "中欧 · 英国物流服务",
|
||||
welcomeEyebrow: "中美物流服务",
|
||||
serviceTag: "物流咨询服务",
|
||||
footerTrack: "查入仓码",
|
||||
footerQuote: "专线报价",
|
||||
footerException: "异常处理",
|
||||
footerQuote: "美国报价",
|
||||
footerException: "异常订单",
|
||||
exceptionQuery: "我有多少个异常订单?",
|
||||
footerHuman: "联系人工",
|
||||
inputShort: "输入入仓码…",
|
||||
uploading: "附件正在上传,请稍候…",
|
||||
@@ -339,10 +357,10 @@ const I18N = {
|
||||
langTip: "中英文切换(Ctrl+L)",
|
||||
toBottom: "回到底部",
|
||||
quick: [
|
||||
"按入仓码查轨迹", "欧洲海运专线报价", "包裹异常处理", "转接人工客服"
|
||||
"按入仓码查轨迹", "美国海运专线报价", "查询异常订单", "转接人工客服"
|
||||
],
|
||||
hotList: [
|
||||
"如何查询物流轨迹?", "欧洲海运时效是多久?", "包裹显示异常签收怎么办?", "如何申请理赔?"
|
||||
"如何查询物流轨迹?", "美国海运时效是多久?", "我有多少个异常订单?", "各个轨迹节点分别有多少订单?"
|
||||
]
|
||||
},
|
||||
en: {
|
||||
@@ -406,11 +424,12 @@ const I18N = {
|
||||
retryAIOnly: "Return to AI chat before regenerating this answer.",
|
||||
clickRetry: "Retry",
|
||||
welcomeTitle: "Your next shipment, made clearer.",
|
||||
welcomeEyebrow: "EUROPE & UK LOGISTICS",
|
||||
welcomeEyebrow: "CHINA–US LOGISTICS",
|
||||
serviceTag: "Logistics support",
|
||||
footerTrack: "Inbound lookup",
|
||||
footerQuote: "Get a quote",
|
||||
footerException: "Parcel issue",
|
||||
footerQuote: "US quote",
|
||||
footerException: "Exception orders",
|
||||
exceptionQuery: "How many exception orders do I have?",
|
||||
footerHuman: "Human support",
|
||||
inputShort: "Inbound code…",
|
||||
uploading: "Uploading your attachment…",
|
||||
@@ -457,11 +476,11 @@ const I18N = {
|
||||
langTip: "Switch language (Ctrl+L)",
|
||||
toBottom: "Back to bottom",
|
||||
quick: [
|
||||
"Track by inbound code", "Europe sea freight quote", "Handle a parcel exception", "Talk to a human agent"
|
||||
"Track by inbound code", "US sea freight quote", "View exception orders", "Talk to a human agent"
|
||||
],
|
||||
hotList: [
|
||||
"How do I track my shipment?", "How long does Europe sea freight take?",
|
||||
"My parcel shows delivered but I didn't get it", "How do I file a claim?"
|
||||
"How do I track my shipment?", "How long does US sea freight take?",
|
||||
"How many exception orders do I have?", "How many orders are at each tracking stage?"
|
||||
]
|
||||
}
|
||||
};
|
||||
@@ -633,7 +652,8 @@ function welcomeHTML(){
|
||||
const h = t("hotList");
|
||||
const kinds = ["track", "quote", "issue", "human"];
|
||||
const cards = q.map(function(question, index){
|
||||
const action = index === 3 ? 'data-human-support' : index === 0 ? 'data-order-lookup' : `data-question="${escHtml(question)}"`;
|
||||
const query = index === 2 ? t('exceptionQuery') : question;
|
||||
const action = index === 3 ? 'data-human-support' : index === 0 ? 'data-order-lookup' : `data-question="${escHtml(query)}"`;
|
||||
return `<button type="button" class="suggest-item" ${action}><span class="suggest-icon">${welcomeIcon(kinds[index])}</span><span class="suggest-copy"><strong>${question}</strong></span><span class="suggest-arrow" aria-hidden="true">↗</span></button>`;
|
||||
}).join("");
|
||||
return `
|
||||
@@ -873,6 +893,7 @@ function ensureConv(){
|
||||
}
|
||||
|
||||
function resetConversation(id){
|
||||
setVoiceStatus('');
|
||||
flushDraft();
|
||||
emojiPicker.close();
|
||||
if(orderLookup) orderLookup.cancel();
|
||||
@@ -2036,7 +2057,7 @@ voiceInput = ChatVoice.create({
|
||||
toggle:document.getElementById('voice-toggle'), hold:document.getElementById('voice-hold'),
|
||||
panel:document.getElementById('voice-panel'), label:document.getElementById('voice-label'),
|
||||
clock:document.getElementById('voice-clock'), inputArea:document.querySelector('.input-area'), input:inputDom,
|
||||
text:t, status:setComposerStatus, language:() => LANG,
|
||||
text:t, status:setVoiceStatus, language:() => LANG,
|
||||
context:() => conversationVersion + ':' + currentConvId + ':' + window.humanSid,
|
||||
canStart:function(){
|
||||
if(uploadPending || sendPreparing || window.__busy || openingConv !== null || humanRequestToken || humanSendingToken || humanClosing) return t('voiceWait');
|
||||
@@ -2084,7 +2105,8 @@ document.querySelectorAll('[data-question-index]').forEach(function(button){
|
||||
button.addEventListener('click', function(){
|
||||
const index=Number(button.dataset.questionIndex);
|
||||
if(index === 0) return showOrderLookup(undefined,undefined,button);
|
||||
return window.askNewTopic(t('quick')[index]);
|
||||
const question = index === 2 ? t('exceptionQuery') : t('quick')[index];
|
||||
return window.askNewTopic(question);
|
||||
});
|
||||
});
|
||||
chatDom.addEventListener('click', function(event){
|
||||
|
||||
+23
-9
@@ -4,6 +4,7 @@ var ChatVoice = {
|
||||
const fetch = options.fetch || (typeof window.fetch === 'function' ? window.fetch.bind(window) : globalThis.fetch);
|
||||
const {toggle, hold, panel, label, clock, inputArea, input, text, status} = options;
|
||||
let session = null, mode = false;
|
||||
let pressedId = null;
|
||||
const active = current => session === current;
|
||||
function stopTracks(current){
|
||||
if(current.stream) current.stream.getTracks().forEach(track => track.stop());
|
||||
@@ -39,6 +40,7 @@ var ChatVoice = {
|
||||
}
|
||||
function setMode(on){
|
||||
cancel();
|
||||
pressedId=null;
|
||||
mode = on;
|
||||
render();
|
||||
if(on && input.blur) input.blur();
|
||||
@@ -172,24 +174,30 @@ var ChatVoice = {
|
||||
};
|
||||
if(window.PointerEvent){
|
||||
hold.addEventListener('pointerdown', function(event){
|
||||
if(event.button !== 0) return;
|
||||
if(event.button !== 0 || pressedId!==null) return;
|
||||
event.preventDefault();
|
||||
pressedId=event.pointerId;
|
||||
if(hold.setPointerCapture) try{ hold.setPointerCapture(event.pointerId); }catch(error){}
|
||||
start(event.pointerId, event.clientY);
|
||||
});
|
||||
hold.addEventListener('pointermove', event => move(event.pointerId, event.clientY));
|
||||
hold.addEventListener('pointerup', function(event){
|
||||
event.preventDefault();
|
||||
if(pressedId===event.pointerId) pressedId=null;
|
||||
if(session && session.id === event.pointerId) finish(session, false);
|
||||
});
|
||||
['pointercancel','lostpointercapture'].forEach(name => hold.addEventListener(name, function(){
|
||||
pressedId=null;
|
||||
if(session && session.holding) finish(session, true);
|
||||
}));
|
||||
}else{
|
||||
hold.addEventListener('touchstart', function(event){
|
||||
event.preventDefault();
|
||||
if(event.touches.length !== 1) return;
|
||||
const touch=event.touches[0]; start(touch.identifier, touch.clientY);
|
||||
if(pressedId!==null) return;
|
||||
const touch=event.touches[0];
|
||||
pressedId=touch.identifier;
|
||||
start(touch.identifier, touch.clientY);
|
||||
}, {passive:false});
|
||||
hold.addEventListener('touchmove', function(event){
|
||||
event.preventDefault();
|
||||
@@ -197,23 +205,29 @@ var ChatVoice = {
|
||||
}, {passive:false});
|
||||
hold.addEventListener('touchend', function(event){
|
||||
event.preventDefault();
|
||||
for(const touch of event.changedTouches) if(session && session.id === touch.identifier) finish(session, false);
|
||||
for(const touch of event.changedTouches){
|
||||
if(pressedId===touch.identifier) pressedId=null;
|
||||
if(session && session.id === touch.identifier) finish(session, false);
|
||||
}
|
||||
}, {passive:false});
|
||||
hold.addEventListener('touchcancel', function(){ if(session) finish(session, true); });
|
||||
hold.addEventListener('touchcancel', function(){ pressedId=null;if(session) finish(session, true); });
|
||||
}
|
||||
hold.addEventListener('contextmenu', event => event.preventDefault());
|
||||
hold.addEventListener('keydown', function(event){
|
||||
if(![' ','Enter'].includes(event.key) || event.repeat) return;
|
||||
if(![' ','Enter'].includes(event.key) || event.repeat || pressedId!==null) return;
|
||||
pressedId='keyboard';
|
||||
event.preventDefault(); start('keyboard', 0);
|
||||
});
|
||||
hold.addEventListener('keyup', function(event){
|
||||
if([' ','Enter'].includes(event.key) && session && session.id === 'keyboard'){
|
||||
event.preventDefault(); finish(session, false);
|
||||
if([' ','Enter'].includes(event.key)){
|
||||
if(pressedId==='keyboard') pressedId=null;
|
||||
event.preventDefault();
|
||||
if(session && session.id === 'keyboard') finish(session, false);
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', event => { if(event.key === 'Escape') cancel(); });
|
||||
document.addEventListener('visibilitychange', function(){ if(document.hidden) cancel(); });
|
||||
window.addEventListener('pagehide', function(){ cancel(); });
|
||||
document.addEventListener('visibilitychange', function(){ if(document.hidden){pressedId=null;cancel();} });
|
||||
window.addEventListener('pagehide', function(){ pressedId=null;cancel(); });
|
||||
render();
|
||||
return {cancel:cancel,setMode:setMode,refresh:render,busy:() => Boolean(session)};
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<section class="ai-welcome-card">
|
||||
<div class="ai-welcome-top">
|
||||
<div class="ai-avatar"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m4 7 8-4 8 4v10l-8 4-8-4Z"/><path d="m4 7 8 4 8-4M12 11v10M8 5l8 4"/></svg></div>
|
||||
<div class="welcome-eyebrow">中欧 · 英国物流服务</div>
|
||||
<div class="welcome-eyebrow">中美物流服务</div>
|
||||
<h3>物流问题,随时为您解答</h3>
|
||||
</div>
|
||||
</section>
|
||||
@@ -51,8 +51,8 @@
|
||||
<div id="human-session" class="human-session" role="status" hidden><span id="human-session-label"></span><button id="return-ai" type="button">返回 AI 问答</button></div>
|
||||
<div class="composer-toolbar"><div class="hot-bar">
|
||||
<button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查入仓码</button>
|
||||
<button type="button" class="hot-capsule" data-question-index="1" data-i18n="footerQuote">专线报价</button>
|
||||
<button type="button" class="hot-capsule" data-question-index="2" data-i18n="footerException">异常处理</button>
|
||||
<button type="button" class="hot-capsule" data-question-index="1" data-i18n="footerQuote">美国报价</button>
|
||||
<button type="button" class="hot-capsule" data-question-index="2" data-i18n="footerException">异常订单</button>
|
||||
<button type="button" class="hot-capsule" onclick="showHuman()" data-i18n="footerHuman">联系人工</button>
|
||||
</div><button id="voice-toggle" class="voice-toggle" type="button" aria-label="语音输入" aria-pressed="false" title="语音输入">🎙</button></div>
|
||||
<div class="input-area">
|
||||
@@ -64,6 +64,7 @@
|
||||
<button type="button" id="send-button">发送</button>
|
||||
</div>
|
||||
<p id="composer-status" class="composer-status" role="status" hidden></p>
|
||||
<div id="voice-feedback" class="voice-feedback" role="status" aria-live="polite" hidden></div>
|
||||
</footer>
|
||||
<div id="voice-panel" class="voice-panel" role="status" aria-live="polite" hidden><span class="voice-wave" aria-hidden="true">▂ ▆ █ ▆ ▂</span><p id="voice-label"></p><span id="voice-clock"></span></div>
|
||||
<button type="button" id="to-bottom" class="to-bottom" title="回到底部" aria-label="回到底部">↓</button>
|
||||
|
||||
@@ -76,6 +76,7 @@ async function customerPage(pageOptions = {}){
|
||||
};
|
||||
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); } }
|
||||
let state = {status:'active', messages:[]};
|
||||
let customerState=pageOptions.customer || null;
|
||||
const context = vm.createContext({
|
||||
document, console, Date:TestDate, navigator:{language:'zh-CN',clipboard:pageOptions.clipboard}, AbortController, TextDecoder, confirm:() => true,
|
||||
FormData:class { append(){} },
|
||||
@@ -93,7 +94,14 @@ async function customerPage(pageOptions = {}){
|
||||
clearTimeout:id => timers.delete(id),
|
||||
fetch: async (url, options) => {
|
||||
calls.push({url, body:options && options.body && (typeof options.body === 'string' ? JSON.parse(options.body) : options.body)});
|
||||
if(hooks[url]) return hooks[url](options);
|
||||
if(hooks[url]){
|
||||
const result=await hooks[url](options);
|
||||
if(url==='/api/customer/login' || url==='/api/customer/logout'){
|
||||
const data=await result.json();
|
||||
if(result.ok && data.ok) customerState=url==='/api/customer/login' ? data.user : null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if(url === '/api/get_history' && pageOptions.historyFetch) return pageOptions.historyFetch();
|
||||
if(url.startsWith('/api/visitor/human/poll') && hooks.poll) return hooks.poll(url);
|
||||
if(url.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url);
|
||||
@@ -107,7 +115,7 @@ async function customerPage(pageOptions = {}){
|
||||
}
|
||||
const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'}
|
||||
: url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)}
|
||||
: url === '/api/customer/session' ? {ok:true,authenticated:Boolean(pageOptions.customer),user:pageOptions.customer || null}
|
||||
: url === '/api/customer/session' ? {ok:true,authenticated:Boolean(customerState),user:customerState}
|
||||
: url === '/api/visitor/human/request' ? (pageOptions.serviceClosed ? {ok:false,closed:true} : {ok:true, session_id:7})
|
||||
: url.startsWith('/api/visitor/human/poll') ? state
|
||||
: ['/api/rate', '/api/feedback', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
|
||||
@@ -193,7 +201,53 @@ test('switching to a quick topic discards the previous topic attachment', async
|
||||
app.context.window.__pendingImg='/uploads/20261029100145_6787.txt';
|
||||
app.context.window.__pendingTag='[文件]';
|
||||
await app.click(app.quickButtons[1]);
|
||||
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '欧洲海运专线报价');
|
||||
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '美国海运专线报价');
|
||||
});
|
||||
|
||||
test('the homepage and exception shortcut match the US service in both languages', async () => {
|
||||
for(const language of ['zh','en']){
|
||||
const app=await customerPage({human:false,storage:{'wuliu-lang':language}});
|
||||
await app.click(app.element('.new-chat-btn'));
|
||||
const html=app.element('chatDom').innerHTML;
|
||||
assert.doesNotMatch(html,/欧洲|中欧|英国物流|EUROPE|Europe|理赔|file a claim/);
|
||||
assert.match(html,language==='zh'?/中美物流服务/:/CHINA–US LOGISTICS/);
|
||||
assert.match(html,language==='zh'?/美国海运专线报价/:/US sea freight quote/);
|
||||
assert.match(html,language==='zh'?/查询异常订单/:/View exception orders/);
|
||||
const question=language==='zh'?'我有多少个异常订单?':'How many exception orders do I have?';
|
||||
assert.ok(html.includes('data-question="'+question+'"'));
|
||||
await app.click(app.quickButtons[2]);
|
||||
assert.equal(app.calls.find(call=>call.url==='/stream_chat').body.msg,question);
|
||||
}
|
||||
});
|
||||
|
||||
test('login keeps the chat layout and an overview is requested only by a chat question', async () => {
|
||||
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});
|
||||
assert.equal(app.calls.filter(call=>call.url.includes('overview=1') || call.url==='/stream_chat').length,0);
|
||||
const template=fs.readFileSync(path.join(__dirname,'../../templates/index.html'),'utf8');
|
||||
assert.doesNotMatch(template,/customer-overview|js\/overview\.js/);
|
||||
await app.context.window.fillInput('我的订单概览');
|
||||
await flush();
|
||||
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,1);
|
||||
assert.equal(app.calls.find(call=>call.url==='/stream_chat').body.msg,'我的订单概览');
|
||||
assert.match(app.element('chatDom').textContent,/测试 AI 回复/);
|
||||
});
|
||||
|
||||
test('voice errors use a floating notice without changing the composer status', async () => {
|
||||
const app=await customerPage({human:false,width:390});
|
||||
const status=app.element('composer-status');
|
||||
app.voiceOptions.status('语音输入失败,请重试。',true);
|
||||
const feedback=app.element('voice-feedback');
|
||||
assert.equal(feedback.hidden,false);
|
||||
assert.equal(feedback.textContent,'语音输入失败,请重试。');
|
||||
assert.ok(feedback.classList.contains('is-error'));
|
||||
assert.equal(status.textContent,'');
|
||||
app.voiceOptions.status('语音输入失败,请重试。',true);
|
||||
assert.equal(feedback.hidden,false);
|
||||
await app.runTimers(4000);
|
||||
assert.equal(feedback.hidden,true);
|
||||
app.voiceOptions.status('语音输入失败,请重试。',true);
|
||||
await app.click(app.element('.new-chat-btn'));
|
||||
assert.equal(feedback.hidden,true);
|
||||
});
|
||||
|
||||
test('fragmented CRLF event streams preserve UTF-8 text and complete normally', async () => {
|
||||
@@ -390,7 +444,7 @@ test('tracking does not interrupt human support while quote and exception shortc
|
||||
const requests = app.calls.filter(call => call.url === '/stream_chat');
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 2);
|
||||
assert.deepEqual(requests.map(call => call.body.msg), ['欧洲海运专线报价', '包裹异常处理']);
|
||||
assert.deepEqual(requests.map(call => call.body.msg), ['美国海运专线报价', '我有多少个异常订单?']);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
|
||||
assert.equal(app.context.window.humanSid, 0);
|
||||
assert.equal(app.element('human-session').hidden, true);
|
||||
@@ -398,14 +452,14 @@ test('tracking does not interrupt human support while quote and exception shortc
|
||||
|
||||
test('welcome and hot question buttons use the same AI routing as the footer', async () => {
|
||||
const app = await customerPage();
|
||||
for(const question of ['欧洲海运专线报价', '如何申请理赔?']){
|
||||
for(const question of ['美国海运专线报价', '各个轨迹节点分别有多少订单?']){
|
||||
const button = app.element('question-'+question);
|
||||
button.dataset.question = question;
|
||||
app.element('chatDom').listeners.click({target:button});
|
||||
await flush();
|
||||
}
|
||||
const requests = app.calls.filter(call => call.url === '/stream_chat');
|
||||
assert.deepEqual(requests.map(call => call.body.msg), ['欧洲海运专线报价', '如何申请理赔?']);
|
||||
assert.deepEqual(requests.map(call => call.body.msg), ['美国海运专线报价', '各个轨迹节点分别有多少订单?']);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
|
||||
});
|
||||
|
||||
@@ -464,7 +518,7 @@ test('a late conversation creation cannot replace the newest quick question', as
|
||||
const requests = app.calls.filter(call => call.url === '/stream_chat');
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].body.conv_id, 22);
|
||||
assert.equal(requests[0].body.msg, '包裹异常处理');
|
||||
assert.equal(requests[0].body.msg, '我有多少个异常订单?');
|
||||
assert.equal(app.context.window.__busy, false);
|
||||
});
|
||||
|
||||
|
||||
@@ -97,6 +97,26 @@ test('short presses and unavailable service do not upload recordings',async()=>{
|
||||
assert.equal(disabled.statuses.at(-1)[0],'voiceUnavailable');
|
||||
});
|
||||
|
||||
test('a failed physical hold cannot restart until it has been released',async()=>{
|
||||
for(const touch of [false,true]){
|
||||
const app=voicePage({enabled:false,touch});
|
||||
app.open();
|
||||
const point={identifier:7,clientY:300};
|
||||
const down=async()=>{
|
||||
if(touch) app.elements.hold.listeners.touchstart({touches:[point],preventDefault(){}});
|
||||
else app.elements.hold.listeners.pointerdown(app.event());
|
||||
await flush();
|
||||
};
|
||||
await down();await down();
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/speech/config').length,1);
|
||||
if(touch) app.elements.hold.listeners.touchend({changedTouches:[point],preventDefault(){}});
|
||||
else app.elements.hold.listeners.pointerup(app.event());
|
||||
await down();
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/speech/config').length,2);
|
||||
assert.equal(app.permissionCalls,0);
|
||||
}
|
||||
});
|
||||
|
||||
test('late microphone permission after cancellation immediately stops its tracks',async()=>{
|
||||
let grant;const app=voicePage({permission:()=>new Promise(resolve=>{grant=resolve;})});
|
||||
app.open();await app.down();await app.up();grant(app.stream);await flush();
|
||||
|
||||
@@ -103,6 +103,47 @@ class CustomerGapTests(unittest.TestCase):
|
||||
self.assertIn('最高项70元/件',text)
|
||||
self.assertNotIn('请补充箱数',text)
|
||||
|
||||
def test_box_units_are_local_and_each_axis_can_carry_its_own_unit(self):
|
||||
cases=(('12英寸显示器,包装60x40x30cm',[60,40,30],'cm',Decimal('30')),
|
||||
('80cm×70cm×60cm',[80,70,60],'cm',Decimal('56')),
|
||||
('80cmx70cmx60cm',[80,70,60],'cm',Decimal('56')),
|
||||
('800mm*700mm*600mm',[800,700,600],'mm',Decimal('56')),
|
||||
('长80厘米宽70厘米高60厘米',[80,70,60],'cm',Decimal('56')),
|
||||
('0.8m×70cm×600mm',[80,70,60],'cm',Decimal('56')),
|
||||
('12英寸显示器,包装60x40x30',[60,40,30],'cm',Decimal('30')))
|
||||
for wording,dims,unit,charge in cases:
|
||||
with self.subTest(wording=wording):
|
||||
intent=parse_intent('美国美西30KG,'+wording+',1箱义乌普船海派',set())
|
||||
self.assertEqual(intent['dims'],dims);self.assertEqual(intent['dim_unit'],unit)
|
||||
self.assertEqual(quantity(intent,{'kind':'parcel','unit':'kg'}),charge)
|
||||
text=self.ask('美国美西30KG,'+wording+',1箱义乌普船海派')
|
||||
self.assertIn(f'{charge}KG =',text)
|
||||
self.assertNotIn('请补充箱数',text)
|
||||
|
||||
def test_oral_partial_weight_edits_never_overwrite_the_whole_shipment(self):
|
||||
cid=self.conversation()
|
||||
self.ask('美国美西30KG,部分铁制品,铁铝部分计费重10KG,义乌普船海派',cid)
|
||||
for q,amount in (('铁铝部分还是10KG',110),('铁铝部分改成8KG',100),('铁铝部分仍是8KG',100)):
|
||||
text=self.ask(q,cid)
|
||||
self.assertIn('美国 30KG',text);self.assertIn(f'30KG = {amount}元',text)
|
||||
self.assertIn('大于0',self.ask('铁铝部分改为-1KG',cid))
|
||||
fresh=self.conversation()
|
||||
self.assertIn('请确认铁铝部分的计费重',self.ask('美国美西30KG,铁铝部分还是10KG,义乌普船海派',fresh))
|
||||
self.assertNotIn('×10KG',self.ask('一共多少钱,给我最终总价',fresh))
|
||||
|
||||
def test_long_quote_chain_retains_conditions_but_topic_changes_stop_reuse(self):
|
||||
cid=self.conversation();self.ask('美国美西30KG,义乌普船海派',cid)
|
||||
for _ in range(15):self.assertIn('每箱不足12KG',self.ask('这个报价按毛重还是净重?',cid))
|
||||
self.assertIn('美国 50KG',self.ask('50KG呢',cid))
|
||||
self.assertIn('美国渠道',self.ask('这个物流服务范围具体怎么执行?',cid))
|
||||
self.assertIn('请先',self.ask('下一页',cid))
|
||||
from logistics_chat.quote_service import answer_quote
|
||||
from logistics_chat.quote_scope import answer_us_fallback
|
||||
with self.app.test_request_context('/'):
|
||||
self.assertIsNone(answer_quote('新的一票50KG发德国报价',cid))
|
||||
db.save_message(cid,'user','换个话题,我要查订单')
|
||||
self.assertIsNone(answer_us_fallback('50KG呢',cid))
|
||||
|
||||
def test_comparing_two_weights_is_not_treated_as_two_cartons(self):
|
||||
cid=self.conversation();self.ask('美国30KG海派报价',cid)
|
||||
text=self.ask('单箱12.5KG会不会按13KG收费?',cid)
|
||||
@@ -194,6 +235,13 @@ class CustomerGapTests(unittest.TestCase):
|
||||
self.ask('美国30KG海派报价',cid)
|
||||
self.assertIn('请先',self.ask('下一页',cid))
|
||||
|
||||
def test_count_interruptions_do_not_erase_the_active_list_page(self):
|
||||
self.seed_pages();self.login();cid=self.conversation()
|
||||
self.ask('列出我的全部订单',cid)
|
||||
self.ask('我有多少个运单',cid)
|
||||
text=self.ask('下一页',cid)
|
||||
self.assertIn('第2页',text);self.assertEqual(text.count('- 入仓码:'),6)
|
||||
|
||||
def test_clearance_followup_modification_and_unknown_payment_do_not_call_model(self):
|
||||
self.assertTrue(self.events('我的订单已清关了吗')[0]['login_required'])
|
||||
self.login();cid=self.conversation();self.events('QAFOCUS20260001',cid)
|
||||
|
||||
@@ -78,14 +78,15 @@ class OrderCountTests(unittest.TestCase):
|
||||
'What is the number of my orders?'):
|
||||
with self.subTest(question=question):
|
||||
events = self.events(question,cid)
|
||||
self.assertEqual(events[0],{'t':'您当前共有 2 票订单,其中 1 票已登记入仓码。'})
|
||||
self.assertTrue(events[0]['t'].startswith('您当前共有 2 票订单,其中 1 票已登记入仓码。'))
|
||||
self.assertEqual(events[0]['t'].count('- 入仓码:'),2)
|
||||
self.assertEqual(events[-1],{'d':1})
|
||||
model.assert_not_called()
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
order_data.import_records(connection,[{'tenant_id':'tenant-a','order_no':'COUNT20260004','inbound_code':'INB20260004'}])
|
||||
connection.commit(); connection.close()
|
||||
self.assertEqual(self.events('我有多少个运单')[0]['t'],'您当前共有 3 票订单,其中 2 票已登记入仓码。')
|
||||
self.assertTrue(self.events('我有多少个运单')[0]['t'].startswith('您当前共有 3 票订单,其中 2 票已登记入仓码。'))
|
||||
|
||||
def test_business_status_filters_are_exact_and_unknown_conditions_never_return_the_total(self):
|
||||
self.login()
|
||||
@@ -152,7 +153,7 @@ class OrderCountTests(unittest.TestCase):
|
||||
with self.app.app_context():
|
||||
with self.assertRaises(order_data.OrderDataUnavailable):
|
||||
order_data.count_orders(('tenant-a',))
|
||||
with patch('logistics_chat.order_data.count_orders',side_effect=order_data.OrderDataUnavailable):
|
||||
with patch('logistics_chat.order_listing.read_orders',side_effect=sqlite3.OperationalError):
|
||||
events = self.events('我有多少个运单')
|
||||
self.assertIn('暂时无法读取',events[0]['t'])
|
||||
|
||||
@@ -164,7 +165,7 @@ class OrderCountTests(unittest.TestCase):
|
||||
(' \t\r\n','COUNT20260002','tenant-a'))
|
||||
order_data.import_records(connection,[{'tenant_id':'tenant-a','order_no':'COUNT20260005','inbound_code':'INB20260001'}])
|
||||
connection.commit(); connection.close()
|
||||
self.assertEqual(self.events('我有多少个运单')[0]['t'],'您当前共有 3 票订单,其中 2 票已登记入仓码。')
|
||||
self.assertTrue(self.events('我有多少个运单')[0]['t'].startswith('您当前共有 3 票订单,其中 2 票已登记入仓码。'))
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute("DELETE FROM order_test_data WHERE tenant_id='tenant-a'")
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Statistics use explicit exception reasons and actual, order-level progress."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from logistics_chat import create_app,db,order_data
|
||||
from logistics_chat.order_tracking import current_stage
|
||||
|
||||
|
||||
def fact(code,shipment='',forecast=False):
|
||||
return {'code':code,'shipment_key':shipment,
|
||||
'estimated_at' if forecast else 'actual_at':'2026-10-01 10:00'}
|
||||
|
||||
|
||||
class OrderStatisticsTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup);root=Path(folder.name)
|
||||
self.app=create_app({'TESTING':True,'DATABASE':root/'data.db','UPLOAD_DIR':root/'uploads',
|
||||
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'',
|
||||
'CUSTOMER_DEMO_TENANT_ID':'a','RATE_LIMIT_PER_MIN':1000})
|
||||
self.client=self.app.test_client();self.client.post('/api/visitor/session',json={'key':'1'*64})
|
||||
rows=[{'tenant_id':'a','order_no':f'STAT2026{i:04}','inbound_code':f'INSTAT2026{i:04}',
|
||||
'status':'PROCESSING','source_updated_at':f'2026-10-{i+1:02} 10:00',
|
||||
'tracking_json':[fact('IN_TRANSIT'),fact('ARRIVED_DESTINATION_PORT'),fact('EMPTY_RETURNED',forecast=True)],
|
||||
'exception_reason':f'待核实资料{i}' if i<6 else None} for i in range(10)]
|
||||
rows += [{'tenant_id':'a','order_no':'CLEAR20260001','status':'PROCESSING',
|
||||
'tracking_json':[fact('ARRIVED_DESTINATION_PORT'),fact('PICKUP_READY')],'exception_reason':'等待客户资料'},
|
||||
{'tenant_id':'a','order_no':'CLEAR20260002','status':'PROCESSING','tracking_json':[fact('PICKUP_READY')]},
|
||||
{'tenant_id':'a','order_no':'MIXED20260001','tracking_json':[fact('ARRIVED_DESTINATION_PORT','A'),fact('PICKUP_READY','B')]},
|
||||
{'tenant_id':'a','order_no':'FORECAST20260001','tracking_json':[fact('ARRIVED_DESTINATION_PORT',forecast=True)]},
|
||||
{'tenant_id':'a','order_no':'BUSINESS20260001','status':'COMPLETED','tracking_json':'[]'},
|
||||
{'tenant_id':'a','order_no':'REJECTED20260001','status':'REJECTED','exception_reason':' \t\n'},
|
||||
{'tenant_id':'b','order_no':'SECRET20260001','status':'PROCESSING','tracking_json':[fact('ARRIVED_DESTINATION_PORT')],'exception_reason':'不能泄露'}]
|
||||
with self.app.app_context():
|
||||
c=db.connect();order_data.import_records(c,rows);c.commit();c.close()
|
||||
|
||||
def login(self):
|
||||
self.assertTrue(self.client.post('/api/customer/login',json={'username':'admin','password':'123456'}).json['ok'])
|
||||
|
||||
def ask(self,q,cid=None):
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
response=self.client.post('/stream_chat',json={'msg':q,'conv_id':cid})
|
||||
events=[json.loads(line[6:]) for line in response.get_data(as_text=True).split('\n\n') if line.startswith('data: ')]
|
||||
model.assert_not_called()
|
||||
self.assertEqual(events[-1],{'d':1},events)
|
||||
self.assertNotIn('_order_list_state',str(events));self.assertNotIn('_customer_data',str(events))
|
||||
self.assertNotIn('SECRET',str(events));self.assertNotIn('不能泄露',str(events))
|
||||
return events[0]
|
||||
|
||||
def test_guests_never_read_aggregate_or_exception_facts(self):
|
||||
with patch('logistics_chat.order_listing.read_orders') as read:
|
||||
for q in ('我有多少异常订单','到港的有多少运单','各个轨迹节点分别有多少订单','查看全部','下一页'):
|
||||
self.assertTrue(self.ask(q)['login_required'])
|
||||
read.assert_not_called()
|
||||
|
||||
def test_overview_is_requested_in_chat_and_saved_in_customer_protected_history(self):
|
||||
with patch('logistics_chat.order_listing.read_overview') as read:
|
||||
for question in ('我的订单概览','查看订单情况','Order overview'):
|
||||
self.assertTrue(self.ask(question)['login_required'])
|
||||
read.assert_not_called()
|
||||
self.login()
|
||||
with patch('logistics_chat.order_listing.read_overview') as read:
|
||||
self.assertNotIn('overview',self.client.get('/api/customer/session?overview=1').json)
|
||||
read.assert_not_called()
|
||||
cid=self.client.post('/api/new_chat').json['id']
|
||||
text=self.ask('我的订单概览',cid)['t']
|
||||
self.assertIn('共有 16 票订单,履约中 12 票,异常订单 7 票',text)
|
||||
self.assertIn('船舶已到目的港:10票',text)
|
||||
self.assertEqual(text.count('- 入仓码:'),5)
|
||||
self.assertEqual(text.count('异常原因:'),5)
|
||||
response=self.client.get('/api/get_messages',query_string={'conv_id':cid})
|
||||
self.assertIn('no-store',response.headers['Cache-Control'])
|
||||
self.assertTrue(any(row['role']=='assistant' and row['content']==text for row in response.json))
|
||||
self.client.post('/api/customer/logout')
|
||||
self.assertEqual(self.client.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||
self.login()
|
||||
self.assertTrue(any(row['content']==text for row in self.client.get('/api/get_messages',query_string={'conv_id':cid}).json))
|
||||
|
||||
def test_overview_keeps_old_exception_ahead_of_new_processing_orders_and_refreshes(self):
|
||||
self.login()
|
||||
with self.app.app_context():
|
||||
c=db.connect()
|
||||
c.execute("UPDATE order_test_data SET exception_reason=NULL WHERE tenant_id='a' AND order_no<>'STAT20260000'")
|
||||
c.commit();c.close()
|
||||
from logistics_chat.order_listing import read_overview
|
||||
with self.app.app_context():overview=read_overview(('a',))
|
||||
self.assertEqual(overview['orders'][0]['order_no'],'STAT20260000')
|
||||
self.assertEqual(len({order['order_no'] for order in overview['orders']}),5)
|
||||
with self.app.app_context():
|
||||
c=db.connect()
|
||||
order_data.import_records(c,[{'tenant_id':'a','order_no':'NEW20260001','status':'PROCESSING','exception_reason':'资料待核实'}])
|
||||
c.commit();c.close()
|
||||
refreshed=self.ask('订单概览')['t']
|
||||
self.assertIn('共有 17 票订单,履约中 13 票,异常订单 2 票',refreshed)
|
||||
|
||||
def test_overview_read_failure_keeps_login_available_and_no_facts_leak(self):
|
||||
self.login()
|
||||
with patch('logistics_chat.order_listing.read_overview',side_effect=sqlite3.OperationalError('private details')):
|
||||
result=self.ask('订单概览')
|
||||
self.assertTrue(self.client.get('/api/customer/session').json['authenticated'])
|
||||
self.assertIn('暂时无法读取',result['t'])
|
||||
self.assertNotIn('共有',result['t'])
|
||||
self.assertNotIn('private details',str(result))
|
||||
|
||||
def test_summaries_show_at_most_five_recent_rows_and_keep_intersecting_filters(self):
|
||||
self.login()
|
||||
total=self.ask('我有多少个订单')['t']
|
||||
self.assertIn('共有 16 票',total);self.assertEqual(total.count('- 入仓码:'),5)
|
||||
abnormal=self.ask('我有多少个异常订单')['t']
|
||||
self.assertIn('7 票明确标记异常',abnormal);self.assertEqual(abnormal.count('- 入仓码:'),5)
|
||||
self.assertNotIn('REJECTED20260001',abnormal)
|
||||
arrived=self.ask('现在到港的有多少运单')['t']
|
||||
self.assertIn('10 票当前节点为“船舶已到目的港”',arrived)
|
||||
self.assertEqual(arrived.count('- 入仓码:'),5)
|
||||
self.assertIn('STAT20260009',arrived);self.assertNotIn('STAT20260000',arrived)
|
||||
self.assertNotIn('CLEAR20260001',arrived)
|
||||
text=self.ask('已清关的异常订单有多少')['t']
|
||||
self.assertIn('1 票当前节点为“已清关、可提柜”',text);self.assertIn('等待客户资料',text)
|
||||
|
||||
def test_combined_counts_answer_each_question_and_expand_the_last_filter(self):
|
||||
question='我的订单有多少个,异常有多少个'
|
||||
with patch('logistics_chat.order_listing.read_orders') as read:
|
||||
self.assertTrue(self.ask(question)['login_required'])
|
||||
self.assertTrue(self.ask('异常的有多少条?')['login_required'])
|
||||
read.assert_not_called()
|
||||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||||
text=self.ask(question,cid)['t']
|
||||
self.assertIn('共有 16 票订单',text)
|
||||
self.assertIn('7 票明确标记异常',text)
|
||||
self.assertEqual(text.count('- 入仓码:'),5)
|
||||
self.assertEqual(text.count('异常原因:'),5)
|
||||
self.assertIn('共7票',self.ask('查看全部',cid)['t'])
|
||||
self.assertIn('7 票明确标记异常',self.ask('异常的有多少条?',cid)['t'])
|
||||
self.assertIn('共7票',self.ask('查看全部',cid)['t'])
|
||||
|
||||
def test_spoken_count_questions_tolerate_greetings_and_locate_all_current_nodes(self):
|
||||
self.login()
|
||||
text=self.ask('你好你好,现在我的订单在哪里了?有多少个?')['t']
|
||||
self.assertIn('共有 16 票订单',text)
|
||||
self.assertIn('船舶已到目的港:10票',text)
|
||||
self.assertIn('暂无实际节点:3票',text)
|
||||
self.assertIn('共有 16 票订单',self.ask('或者订单有多少条?')['t'])
|
||||
|
||||
def test_combined_statistics_keep_node_distribution_before_exception_count(self):
|
||||
self.login()
|
||||
text=self.ask('我的订单各个轨迹节点分别有多少,异常的有多少条')['t']
|
||||
self.assertIn('共有 16 票订单',text)
|
||||
self.assertIn('船舶已到目的港:10票',text)
|
||||
self.assertIn('已清关、可提柜:2票',text)
|
||||
self.assertIn('暂无实际节点:3票',text)
|
||||
self.assertIn('7 票明确标记异常',text)
|
||||
self.assertEqual(text.count('- 入仓码:'),5)
|
||||
|
||||
def test_ambiguous_speech_answers_the_clear_count_and_requests_the_missing_meaning(self):
|
||||
self.login()
|
||||
text=self.ask('当前,我的订单有多少条,一层有多少条?')['t']
|
||||
self.assertIn('共有 16 票订单',text)
|
||||
self.assertIn('“一层有多少条”的筛选条件尚未明确',text)
|
||||
self.assertNotIn('7 票明确标记异常',text)
|
||||
text=self.ask('我的订单有多少个,今天异常订单有多少个')['t']
|
||||
self.assertIn('共有 16 票订单',text)
|
||||
self.assertIn('“今天异常订单有多少个”的筛选条件尚未明确',text)
|
||||
|
||||
def test_distribution_counts_each_order_once_and_never_uses_forecasts_or_business_completion(self):
|
||||
self.login()
|
||||
text=self.ask('各个轨迹节点分别有多少订单')['t']
|
||||
for token in ('船舶已到目的港:10票','已清关、可提柜:2票','多柜进度不同:1票','暂无实际节点:3票'):
|
||||
self.assertIn(token,text)
|
||||
self.assertNotIn('已还柜:',text);self.assertNotIn('已签收:',text)
|
||||
|
||||
def test_english_homepage_questions_apply_their_intended_filters(self):
|
||||
self.assertTrue(self.ask('How many exception orders do I have?')['login_required'])
|
||||
self.login()
|
||||
self.assertIn('7 票明确标记异常',self.ask('How many exception orders do I have?')['t'])
|
||||
text=self.ask('How many orders are at each tracking stage?')['t']
|
||||
self.assertIn('船舶已到目的港:10票',text)
|
||||
self.assertIn('暂无实际节点:3票',text)
|
||||
|
||||
def test_expand_and_pages_keep_filters_and_refresh_counts(self):
|
||||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||||
self.ask('我有多少异常订单',cid)
|
||||
text=self.ask('下一页',cid)['t']
|
||||
self.assertIn('第2页',text);self.assertEqual(text.count('- 入仓码:'),2)
|
||||
text=self.ask('查看全部',cid)['t']
|
||||
self.assertIn('共7票',text);self.assertEqual(text.count('- 入仓码:'),7)
|
||||
self.assertIn('明确标记异常',text)
|
||||
with self.app.app_context():
|
||||
c=db.connect();c.execute('UPDATE order_test_data SET exception_reason=NULL WHERE tenant_id=?',('a',));c.commit();c.close()
|
||||
self.assertIn('没有符合条件',self.ask('下一页',cid)['t'])
|
||||
|
||||
def test_pages_do_not_cross_conversations_or_login_boundaries(self):
|
||||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||||
self.ask('到港的订单有多少',cid)
|
||||
self.assertIn('第2页',self.ask('下一页',cid)['t'])
|
||||
self.ask('当前订单总数',cid)
|
||||
self.assertIn('共16票',self.ask('查看全部',cid)['t'])
|
||||
self.client.post('/api/customer/logout')
|
||||
self.assertTrue(self.ask('下一页',cid)['login_required'])
|
||||
self.assertEqual(self.client.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||
self.login();self.assertIn('共16票',self.ask('查看全部',cid)['t'])
|
||||
other=self.client.post('/api/new_chat').json['id']
|
||||
self.assertIn('请先',self.ask('下一页',other)['t'])
|
||||
|
||||
def test_unknown_or_ambiguous_filters_do_not_degenerate_to_totals(self):
|
||||
self.login()
|
||||
for q in ('今天有多少异常订单','未到港的订单有多少','到港和已清关分别有多少订单','已签收的有多少运单'):
|
||||
with self.subTest(q=q):self.assertIn('暂不支持按这个条件统计',self.ask(q)['t'])
|
||||
|
||||
def test_exception_reasons_are_editable_and_old_reimports_preserve_them(self):
|
||||
self.login()
|
||||
with self.app.app_context():
|
||||
c=db.connect();order_data.import_records(c,[{'tenant_id':'a','order_no':'CLEAR20260001','status':'PROCESSING'}],replace=True);c.commit();c.close()
|
||||
self.assertIn('等待客户资料',self.ask('CLEAR20260001的异常原因是什么')['t'])
|
||||
cid=self.client.post('/api/new_chat').json['id'];self.ask('CLEAR20260001',cid)
|
||||
self.assertIn('等待客户资料',self.ask('为什么异常',cid)['t'])
|
||||
with self.app.app_context():
|
||||
c=db.connect();order_data.import_records(c,[{'tenant_id':'a','order_no':'CLEAR20260001','exception_reason':''}],replace=True);c.commit();c.close()
|
||||
self.assertIn('未登记异常原因',self.ask('CLEAR20260001的异常原因是什么')['t'])
|
||||
|
||||
def test_missing_actuals_and_bad_tracking_do_not_count_as_arrived(self):
|
||||
self.assertEqual(current_stage([fact('ARRIVED_DESTINATION_PORT','A'),fact('PICKUP_READY','B',True)]),'MIXED')
|
||||
self.assertEqual(current_stage([fact('ARRIVED_DESTINATION_PORT',forecast=True)]),'NO_ACTUAL')
|
||||
self.assertEqual(current_stage('{broken'),'NO_ACTUAL')
|
||||
self.assertEqual(current_stage([fact('UNKNOWN')]),'NO_ACTUAL')
|
||||
self.assertEqual(current_stage([fact('DOMESTIC_RECEIVED'),fact('ARRIVED_DESTINATION_PORT','A',True)]),'DOMESTIC_RECEIVED')
|
||||
|
||||
def test_node_names_do_not_collide_with_business_status_or_drop_negations(self):
|
||||
from logistics_chat.order_query import parse_query
|
||||
intent=parse_query('已完成装柜的订单有多少')
|
||||
self.assertFalse(intent['unsupported']);self.assertIsNone(intent['status'])
|
||||
self.assertEqual(intent['stage'],'LOADING_COMPLETED')
|
||||
self.assertTrue(parse_query('未清关的订单有多少')['unsupported'])
|
||||
self.assertTrue(parse_query('没有异常的订单有多少')['unsupported'])
|
||||
|
||||
def test_state_survives_app_restart_and_rejects_late_reply_updates(self):
|
||||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||||
self.ask('我有多少异常订单',cid)
|
||||
restarted=create_app(dict(self.app.config,INITIALIZE_RUNTIME=False))
|
||||
client=restarted.test_client()
|
||||
# Copy opaque cookies without exporting them.
|
||||
for cookie in self.client._cookies.values():client.set_cookie(cookie.key,cookie.value)
|
||||
response=client.post('/stream_chat',json={'msg':'下一页','conv_id':cid}).get_data(as_text=True)
|
||||
self.assertIn('第2页',response)
|
||||
from logistics_chat.order_listing import save_state
|
||||
cookies='; '.join(cookie.key+'='+cookie.value for cookie in self.client._cookies.values())
|
||||
with self.app.test_request_context('/',headers={'Cookie':cookies}):
|
||||
self.app.preprocess_request()
|
||||
from logistics_chat.customer_service import current_customer_id
|
||||
self.assertIsNotNone(current_customer_id())
|
||||
c=db.connect();prior=c.execute('SELECT order_list_state FROM chat_history WHERE id=?',(cid,)).fetchone()[0]
|
||||
state=json.loads(prior);c.close()
|
||||
db.save_message(cid,'user','换个话题,我要报价')
|
||||
save_state(cid,{'query':state['query'],'page':1,'size':5},state['message_id'])
|
||||
c=db.connect();self.assertEqual(c.execute('SELECT order_list_state FROM chat_history WHERE id=?',(cid,)).fetchone()[0],prior);c.close()
|
||||
|
||||
def test_schema_migration_preserves_old_orders(self):
|
||||
c=sqlite3.connect(':memory:')
|
||||
try:
|
||||
c.execute('CREATE TABLE order_test_data (id INTEGER PRIMARY KEY,tenant_id TEXT,order_no TEXT,status TEXT,inbound_code TEXT,shipment_id TEXT)')
|
||||
c.execute("INSERT INTO order_test_data VALUES(1,'a','OLD20260001','PROCESSING',NULL,NULL)")
|
||||
order_data.init_order_table(c);order_data.init_order_table(c)
|
||||
self.assertEqual(c.execute('SELECT order_no,exception_reason,tracking_json FROM order_test_data').fetchone(),('OLD20260001',None,'[]'))
|
||||
finally:c.close()
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Fast/slow comparisons preserve shipping conditions and comparable rate groups."""
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from logistics_chat import create_app, db
|
||||
from logistics_chat.quote_data import import_snapshot
|
||||
from logistics_chat.quote_engine import select_timed_rates
|
||||
from test_quotes import fixture_snapshot, frames
|
||||
|
||||
|
||||
def timing_snapshot():
|
||||
book=fixture_snapshot()
|
||||
rates=[]
|
||||
for origin in ('义乌','华南'):
|
||||
for zone,offset in (('美西',0),('美中',1),('美东',2)):
|
||||
for service,price,eta in (('美森正班海派',8,'船开后15天左右'),
|
||||
('合德海派',6,'船开后20-22天左右'),
|
||||
('普船海派',4,'船开后25-30天左右')):
|
||||
rates.append({'service':service,'kind':'parcel','origin':origin,'zone':zone,
|
||||
'unit':'kg','tax':'included','min_weight':'12','price':str(price+offset),
|
||||
'eta':eta,'destinations':[],'source_sheet':'合成时效','source_cell':'A1'})
|
||||
book['rates']=rates
|
||||
return book
|
||||
|
||||
|
||||
class QuoteTimingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup)
|
||||
root=Path(folder.name)
|
||||
self.app=create_app({'TESTING':True,'DATABASE':root/'test.db','UPLOAD_DIR':root/'uploads',
|
||||
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'','RATE_LIMIT_PER_MIN':1000})
|
||||
self.client=self.app.test_client()
|
||||
self.client.post('/api/visitor/session',json={'key':'2'*64})
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
import_snapshot(connection,timing_snapshot(),activate=True)
|
||||
connection.commit();connection.close()
|
||||
|
||||
def ask(self,question,cid=None):
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
events=frames(self.client.post('/stream_chat',json={'msg':question,'conv_id':cid}))
|
||||
model.assert_not_called()
|
||||
self.assertEqual(events[-1],{'d':1},events)
|
||||
return events[0]['t']
|
||||
|
||||
def test_fastest_and_slowest_show_each_region_origin_and_the_correct_amount(self):
|
||||
text=self.ask('我想发一批货,一吨发发到美国走海运,最快的和最慢的价格分别是多少?')
|
||||
fast,slow=text.split('最慢参考方案')
|
||||
self.assertIn('最快参考方案',fast)
|
||||
self.assertIn('美西 8元/KG,1000KG = 8000元',fast)
|
||||
self.assertIn('美东 10元/KG,1000KG = 10000元',fast)
|
||||
self.assertIn('美森正班海派 · 华南',fast)
|
||||
self.assertNotIn('普船海派',fast)
|
||||
self.assertIn('美西 4元/KG,1000KG = 4000元',slow)
|
||||
self.assertIn('美东 6元/KG,1000KG = 6000元',slow)
|
||||
self.assertIn('船开后25-30天左右提取',slow)
|
||||
self.assertNotIn('合德海派',text)
|
||||
|
||||
def test_short_followups_keep_weight_origin_zone_and_selected_comparison(self):
|
||||
cid=self.client.post('/api/new_chat').json['id']
|
||||
self.ask('1000KG到美国走海运,最快和最慢价格分别多少',cid)
|
||||
for question in ('慢的呢?','义乌交仓','美东呢','请给我具体报价'):
|
||||
text=self.ask(question,cid)
|
||||
self.assertIn('美国 1000KG',text)
|
||||
self.assertIn('最慢参考方案',text)
|
||||
self.assertNotIn('最快参考方案',text)
|
||||
self.assertNotIn('华南',text)
|
||||
self.assertIn('1000KG = 6000元',text)
|
||||
text=self.ask('50KG呢',cid)
|
||||
self.assertIn('50KG = 300元',text)
|
||||
self.assertNotIn('1000KG',text)
|
||||
text=self.ask('全部渠道报价',cid)
|
||||
self.assertIn('美森正班海派',text)
|
||||
self.assertIn('合德海派',text)
|
||||
self.assertNotIn('最慢参考方案',text)
|
||||
text=self.ask('新的一批100KG发美国海运报价',cid)
|
||||
self.assertIn('美国 100KG',text)
|
||||
self.assertIn('华南',text)
|
||||
self.assertNotIn('最慢参考方案',text)
|
||||
|
||||
def test_numeric_timing_groups_keep_destinations_tax_and_service_kinds_separate(self):
|
||||
base={'zone':'美西','kind':'parcel','unit':'kg','tax':'included'}
|
||||
rates=[{**base,'eta':'10-35天','service':'wide'},
|
||||
{**base,'eta':'15-25天','service':'narrow'},
|
||||
{**base,'zone':'美东','eta':'40-45天','service':'east'},
|
||||
{**base,'tax':'excluded','eta':'20天','service':'excluded'},
|
||||
{**base,'kind':'truck','eta':'50天','service':'truck'},
|
||||
{**base,'eta':'未提供时效','service':'unknown'}]
|
||||
self.assertEqual({r['service'] for r in select_timed_rates(rates)},
|
||||
{'wide','east','excluded','truck'})
|
||||
self.assertEqual({r['service'] for r in select_timed_rates(rates,slowest=True)},
|
||||
{'wide','east','excluded','truck'})
|
||||
self.assertEqual(select_timed_rates([{**base,'eta':'若干天'}]),[])
|
||||
|
||||
def test_english_comparison_keeps_numbers_and_renders_both_speeds(self):
|
||||
text=self.ask('1000kg to USA by sea, fastest and slowest prices please')
|
||||
self.assertIn('Fastest reference options',text)
|
||||
self.assertIn('Slowest reference options',text)
|
||||
self.assertIn('1000KG = 4000 CNY',text)
|
||||
|
||||
def test_different_regional_times_are_rendered_on_their_own_price_lines(self):
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
connection.execute("UPDATE quote_rates SET eta='船开后18天左右' WHERE service='美森正班海派' AND zone='美东'")
|
||||
connection.commit();connection.close()
|
||||
text=self.ask('美国1000KG海运最快价格,义乌')
|
||||
self.assertIn('美东 10元/KG,1000KG = 10000元(船开后18天左右提取)',text)
|
||||
self.assertIn('美西 8元/KG,1000KG = 8000元',text)
|
||||
|
||||
def test_unknown_times_do_not_become_a_fastest_or_slowest_promise(self):
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
connection.execute("UPDATE quote_rates SET eta='若干天'")
|
||||
connection.commit();connection.close()
|
||||
text=self.ask('美国1000KG海运最快和最慢价格')
|
||||
self.assertIn('最快参考方案暂时无法确定',text)
|
||||
self.assertIn('最慢参考方案暂时无法确定',text)
|
||||
self.assertNotIn('1000KG =',text)
|
||||
Reference in New Issue
Block a user