Compare commits

...
27 changed files with 1135 additions and 213 deletions
+2 -2
View File
@@ -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
View File
@@ -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精度,不把显示近似当作计费进位。
+5 -1
View File
@@ -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'})
+4
View File
@@ -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):
+29 -41
View File
@@ -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
+4 -1
View File
@@ -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
View File
@@ -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':'订单信息暂时无法读取,请稍后重试。'}
+74
View File
@@ -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')}
+25 -6
View File
@@ -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
View File
@@ -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'
+8 -1
View File
@@ -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'):
+48 -23
View File
@@ -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)
+8 -1
View File
@@ -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')))
+3
View File
@@ -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 '),
+7 -4
View File
@@ -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}
+3 -13
View File
@@ -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':'当前尚未包含可用的美国报价表,请提供目的地仓码或邮编及货物信息,联系人工确认。'}
+25 -15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)};
}
+4 -3
View File
@@ -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>
+61 -7
View File
@@ -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);
});
+20
View File
@@ -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();
+48
View File
@@ -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)
+5 -4
View File
@@ -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'")
+267
View File
@@ -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()
+121
View File
@@ -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)