Compare commits

..
25 changed files with 874 additions and 76 deletions
+2
View File
@@ -3,6 +3,8 @@
# The process environment takes precedence, including an explicitly empty value.
# Keep real credentials in .env; never put them in this example or source files.
DEEPSEEK_API_KEY=
# Chat and supporting APIs each have a separate per-IP budget. Use a positive integer.
RATE_LIMIT_PER_MIN=600
# Optional fixed tenant scope for the internal order test dataset. Empty = all imported test rows.
ORDER_DATA_TENANT_ID=
# Default front-end customer account: admin / 123456. It grants no staff access.
+3 -2
View File
@@ -12,8 +12,9 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
| 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 |
| Reply presentation | `static/js/reply.js`: `ChatReply.create`; `chat.js`: `doSend`, network completion and display queue cancellation; `tests/frontend/reply.test.cjs` |
| 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 |
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `quote_intent.py`: `parse_intent`, `quote_question_text`; `quote_service.py`: `answer_quote`; `quote_engine.py`: `quantity`, `render_rates`, `select_cheapest_rates`; `quote_answers.py`: `timing_liability`; `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 / order summaries | `logistics_chat/customer_faq.py`: supported actions and acknowledgements; `quote_scope.py`: US-only fallback; `quote_edits.py`: rejected conditions and quantity adjustments; `quote_comparison.py`: comparable fast/slow costs; `quote_material.py`: partial metal chargeable weight; `order_query.py`: bounded filters; `order_listing.py`: scoped five-row summaries and conversation pages; `order_followups.py`: list ordinals, adjacent rows and subset questions; `order_tracking.py`: `current_stage` |
@@ -60,7 +61,7 @@ Use four-space Python indentation, `snake_case`, `UPPER_SNAKE_CASE` constants, a
Use `tests/test_<feature>.py` and `test_<behavior>`. Mock DeepSeek; configure temporary database/upload/backup/log paths before `create_app`. Pytest: `.\.venv\Scripts\python.exe -m pytest -v`; Linux/macOS: `.venv/bin/python -m pytest -v`.
Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, `voice.test.cjs`, `visitor.test.cjs`, and `drafts.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests.
Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, `voice.test.cjs`, `visitor.test.cjs`, `drafts.test.cjs`, and `reply.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests.
## Commit & Pull Request Guidelines
+2
View File
@@ -118,6 +118,8 @@ cp .env.example .env # 仅首次运行;已有 .env 时跳过
### 配置说明
请求限流默认每 IP 每分钟 600 次,聊天与辅助 API 分别计数。可在根目录 `.env` 中设置 `RATE_LIMIT_PER_MIN=600`,或通过进程、容器环境变量覆盖,取值须为正整数。配置在服务启动时读取,修改后重启服务。首页 GET/HEAD 独立于业务限流额度。
手机语音输入:点击输入区上方的麦克风按钮,再按住说话,松开后转文字并发送;上滑或取消触摸不发送,最长 60 秒。微信和手机浏览器使用同一服务端转写链路;页面必须通过 HTTPS 或本机 localhost 打开,并允许麦克风。旧版内置浏览器不支持录音时仍可文字输入。手机订单信息默认折叠,物流轨迹优先展示。
在根目录 `.env` 配置 `ASR_PROVIDER=baidu`、`BAIDU_ASR_API_KEY` 和 `BAIDU_ASR_SECRET_KEY`,使用已开通短语音识别的百度语音应用凭据,不能混用 OCR 或 DeepSeek 凭据。服务端须安装 FFmpeg 并加入 PATH,也可用 `FFMPEG_BIN` 指定可执行文件绝对路径;Docker 应用镜像已包含 FFmpeg。浏览器 WebM/MP4 录音通过内存管道转为 16 kHz 单声道 PCM,录音不写入上传目录或历史,只发送识别文字。修改后重启服务。可选的 `ASR_PROVIDER=dashscope` 使用 `.env.example` 中独立的 ASR 配置。
+3
View File
@@ -31,6 +31,7 @@ PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整
| 查单交互 | `static/js/order_lookup.js`:`OrderLookup.create`;`chat.js`:`showOrderLookup`、`fillTrackCard`;`support.js`:`copyText` | 快捷入口及当前/历史卡片共用查询框;显式输入后才查单,保留聊天草稿和附件;复制共用安全回退 |
| AI 与 SSE | `logistics_chat/chat_service.py`:`create_chat_stream`、`sse`;`ai_client.py`:`post_completion`、`compress_answer`、`gen_followups` | `routes/chat.py`:`stream_chat`;客户端 `doSend`、`renderError`、`renderFollowups` |
| 人工会话 | `logistics_chat/human_service.py`:`human_*`(含转接、备注、撤回);对应 `routes/human.py` | 输入状态在 `app.extensions['human_typing']`;客户 `startHumanPoll`;客服 `loadQueue`、`poll`、`sendReply`;人工申请每天 8:00–22:00 |
| 回复展示 | `static/js/reply.js`:`ChatReply.create`;`chat.js`:`doSend` | 网络完成与文字展示分别结束;收到结束帧后继续显示已接收文字,停止、切换和错误会清理展示队列;减少动态效果时完整显示 |
| 附件 | `logistics_chat/file_service.py`:`upload_file`、`cleanup_uploads`;`routes/files.py`:`uploaded_file` | 扩展名/大小/目录由配置提供;客户端 `addMsg`,客服端 `appendMsg` |
| 登录、统计、订单、留言、导出 | `logistics_chat/business_service.py`:`admin_login`、`admin_stats`、`my_orders`、`add_ticket`、`list_tickets`、`export_chat` | `routes/admin.py`、`routes/business.py`;登录页 `static/js/admin_login.js` |
| 页面 | `templates/index.html`、`templates/admin.html`、`templates/admin_login.html` | 先加载共享 `static/css/theme.css`;客户与客服脚本先加载 `support.js`(表情、通知、评价);客服再加载 `admin_tools.js`(备注、转接、历史搜索),最后加载 `admin.js`;保留全局事件入口 |
@@ -80,6 +81,8 @@ tests/ # 按业务模块组织的回归
连续追问的列表指代和子集条件由 `order_followups.py` 处理。“第一票”定位服务端保存的已展示回复,取数时重新校验当前客户范围。“其中”沿用已有筛选条件,普通状态追问可以切换条件。礼貌确认语保留当前话题,显式返回报价由 `quote_service.py` 恢复最近报价条件。`quote_intent.py` 分别保存渠道选择和本轮询问主题,计算总价、查询时效与税费时沿用已选方案。
报价支持同时比较最快和最便宜方案,推荐追问保留货物条件并按预算、时效分别列出选择。明确指定渠道会替换先前的比较选择。`quote_question_text` 从混合时效与赔付问句中提取报价条件,`timing_liability` 只引用当前匹配渠道已有的时效赔偿条款,其他费用由人工核实。独立理赔话题保留原有话题边界,其他目的地的上下文需先确认国家再使用美国渠道。
报价增减量按上一轮已确认的重量、体积和分箱明细计算。不同箱规的减箱追问需要更新后的逐箱明细。明确排除的渠道保存在报价条件中,快慢差额由 `quote_comparison.py` 按相同交仓地、区域、计费方式与税费口径计算。列表指代绑定原订单号,相邻票以实际已返回的订单卡片为起点,在当前已展示的序号范围内导航,到边界时引导翻页。
- 路由只解析请求并包装响应;服务组织业务;规则模块提供确定性计算。依赖方向为 `routes → services → rules / db / ai_client`,下层不导入路由或根入口。
+8 -1
View File
@@ -14,6 +14,13 @@ def default_config():
local_config = dotenv_values(ROOT_DIR / '.env', encoding='utf-8-sig', interpolate=False)
api_key = os.environ.get('DEEPSEEK_API_KEY', local_config.get('DEEPSEEK_API_KEY'))
rate_limit_value = os.environ.get('RATE_LIMIT_PER_MIN', local_config.get('RATE_LIMIT_PER_MIN', '600'))
try:
rate_limit_per_min = int(rate_limit_value)
except (TypeError, ValueError):
raise ValueError('RATE_LIMIT_PER_MIN must be a positive integer') from None
if rate_limit_per_min < 1:
raise ValueError('RATE_LIMIT_PER_MIN must be a positive integer')
return {
'DATABASE': ROOT_DIR / 'history.db',
'UPLOAD_DIR': ROOT_DIR / 'uploads',
@@ -40,7 +47,7 @@ def default_config():
'MAX_CONTEXT': 4,
'MAX_ANSWER_CHARS': 800,
'MAX_ANSWER_CHARS_EN': 1400,
'RATE_LIMIT_PER_MIN': 60,
'RATE_LIMIT_PER_MIN': rate_limit_per_min,
'ALLOWED_EXT': set(ALLOWED_EXT),
'MAX_UPLOAD_MB': MAX_UPLOAD_MB,
'HOST': '0.0.0.0',
+1
View File
@@ -6,6 +6,7 @@ import re
def is_acknowledgement(message):
text=re.sub(r'[\s,,。.!!??]','',message).lower()
return bool(re.fullmatch(r'(?:好的?|嗯+|明白了?|了解了?|知道了?|收到|谢谢(?:你|您)?|多谢|没问题|'
r'(?:就|先)?(?:选|按|用)?(?:这个|这一个|这票)(?:方案)?(?:吧|就行)?|'
r'ok(?:ay)?|thanks?|thankyou|gotit|understood)+',text))
+16
View File
@@ -6,6 +6,22 @@ import re
from .quote_fees import document, amount, material_fee, limit_fees
def timing_liability(intent,book,destinations):
from .quote_engine import match_rates
rates=[rate for destination in destinations for rate in match_rates(intent,book,destination)]
services=list(dict.fromkeys(rate['service'] for rate in rates))
lines=[]
for name in services:
terms=[rate['eta'] for rate in rates if rate['service']==name]
if all('无时效赔偿' in term for term in terms):
lines.append(name+'的渠道资料注明“无时效赔偿”。')
elif any('无时效赔偿' in term for term in terms):
lines.append(name+'部分匹配方案注明“无时效赔偿”,需按交仓地和目的仓核对适用条款。')
lines.append('未按参考时效到达所产生的其他费用,需由人工根据实际承运条款核实,当前无法确认赔付金额。')
return '\n'.join(lines)
def answer_business(intent,book):
from .quote_engine import number,exact_number
+7 -3
View File
@@ -6,7 +6,7 @@ from .quote_fees import charge_details
def speed_difference_lines(intent, book, rates, fees):
from .quote_engine import quantity, number, select_timed_rates
from .quote_engine import quantity, number, select_timed_rates,select_cheapest_rates
groups = {}
for rate in rates:
@@ -15,7 +15,10 @@ def speed_difference_lines(intent, book, rates, fees):
lines = []
for (origin, zone, kind, unit, tax), candidates in groups.items():
costs = []
for selected in (select_timed_rates(candidates), select_timed_rates(candidates, slowest=True)):
cheapest=intent.get('selection')=='fastest_cheapest'
comparison=(select_cheapest_rates(intent,book,candidates,fees) if cheapest
else select_timed_rates(candidates,slowest=True))
for selected in (select_timed_rates(candidates),comparison):
values = []
for rate in selected:
amount = quantity(intent, rate)
@@ -30,5 +33,6 @@ def speed_difference_lines(intent, book, rates, fees):
label = ' · '.join(value for value in (origin, zone, '包税' if tax == 'included' else '不包税',
'海派' if kind == 'parcel' else '海卡/海铁') if value)
difference = number(low) if low == high else number(low) + '至' + number(high)
lines.append(f'- {label},快慢方案可计算运费差额为{difference}元。')
pair='最快与最便宜方案' if cheapest else '快慢方案'
lines.append(f'- {label},{pair}可计算运费差额为{difference}元。')
return lines
+1 -1
View File
@@ -143,7 +143,7 @@ def merge_intent(current,previous):
elif pending is not None and current['weight'] is None and current.get('pending_weight') is None:
result['pending_weight']=pending
result['errors'].append(MISSING_WEIGHT_UNIT)
if not current.get('selection') and not current.get('clear_selection') and not current.get('clear_choice'):
if not current.get('selection') and not current.get('clear_selection') and not current.get('clear_choice') and not current.get('services'):
result['selection']=previous.get('selection')
if current.get('clear_selection'):
excluded.update(('services','kinds','transport','air_only','air_requested'))
+31 -12
View File
@@ -148,6 +148,21 @@ def select_timed_rates(rates,slowest=False,by_origin=False):
return [rate for rate,key,timing in ranked if timing==best[key]]
def select_cheapest_rates(intent,book,rates,fees,zone=None):
from .quote_fees import charge_details
ranked=[]
best={}
for rate in rates:
amount=quantity(intent,rate)
if amount is None:continue
key=(zone or rate['zone'],rate['unit'],rate['tax'])
value=amount*Decimal(rate['price'])+charge_details(intent,rate,fees,book)['cny']
ranked.append((rate,key,value))
best[key]=min(best.get(key,value),value)
return [rate for rate,key,value in ranked if value==best[key]]
def render_rates(intent, book, destinations, remote_fees):
from .quote_fees import charge_details
from .quote_language import translate_quote
@@ -161,6 +176,10 @@ def render_rates(intent, book, destinations, remote_fees):
else:
lines.append('美国线路报价:')
if intent.get('quantity_note'):lines.append(intent['quantity_note'])
if intent.get('mode')=='recommend':
lines.append({'fastest':'按您优先时效的要求,可考虑下面的较快方案。',
'cheapest':'按您优先运费的要求,可考虑下面的较低价方案。'}.get(rate_selection,
'更看重时效可选下面的较快方案,更看重运费可选较低价方案。'))
if intent.get('allocations'):
lines.append('按各目的仓分票计算,箱规需逐票核对:')
if intent.get('metal_chargeable_weight') is not None:
@@ -177,13 +196,7 @@ def render_rates(intent, book, destinations, remote_fees):
billing.update(metal_chargeable_weight=None,metal_scope='partial')
rates = match_rates(billing, book, destination)
if rate_selection=='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
rates=select_cheapest_rates(billing,book,rates,fees,zone=destination['zone']) or rates
lines.append('按相同区域、计费单位及税费方式比较的较低价方案:')
if not rates:
lines.append(f"{destination['label']}:当前资料没有匹配的线路价格,提供目的仓码或邮编可继续查询。")
@@ -191,17 +204,20 @@ def render_rates(intent, book, destinations, remote_fees):
if destination['label'] != '美国':
lines.append(destination['label']+':')
selections=[('',rates)]
if rate_selection in ('fastest','slowest','speed_compare'):
if rate_selection in ('fastest','slowest','speed_compare','fastest_cheapest'):
selections=[]
if rate_selection in ('fastest','speed_compare'):
if rate_selection in ('fastest','speed_compare','fastest_cheapest'):
selections.append(('最快参考方案',select_timed_rates(rates,by_origin=intent.get('mode')=='difference')))
if rate_selection in ('slowest','speed_compare'):
selections.append(('最慢参考方案',select_timed_rates(rates,slowest=True,by_origin=intent.get('mode')=='difference')))
lines.append('按相同区域、运输方式、计费单位及税费方式比较参考时效。最快按天数下限、最慢按天数上限选择。')
if rate_selection=='fastest_cheapest':
selections.append(('最便宜参考方案',select_cheapest_rates(billing,book,rates,fees,zone=destination['zone'])))
lines.append('按相同区域、运输方式、计费单位及税费方式比较。' if rate_selection=='fastest_cheapest'
else '按相同区域、运输方式、计费单位及税费方式比较参考时效。最快按天数下限、最慢按天数上限选择。')
grouped = {}
for selection,selected_rates in selections:
if selection and not selected_rates:
lines.append(selection+'暂时无法确定,匹配线路未列明可比较的天数。')
lines.append(selection+('暂时无法确定,请提供货物重量和箱规。' if selection=='最便宜参考方案' else '暂时无法确定,匹配线路未列明可比较的天数。'))
for rate in selected_rates:
key = (selection, rate['service'], rate['origin'], rate['unit'], rate['tax'], rate['eta'])
grouped.setdefault(key, []).append(rate)
@@ -239,7 +255,8 @@ def render_rates(intent, book, destinations, remote_fees):
from .quote_comparison import speed_difference_lines
differences=speed_difference_lines(billing,book,rates,fees)
if differences:
lines.append('以下按同一交仓地、区域、运输方式和税费口径计算,差额为最快方案减去最慢方案。')
compared='最便宜' if rate_selection=='fastest_cheapest' else '最慢'
lines.append(f'以下按同一交仓地、区域、运输方式和税费口径计算,差额为最快方案减去{compared}方案。')
lines.extend(differences)
else:
lines.append('请提供重量、箱规及有明确数值时效的渠道,继续核算快慢方案差额。')
@@ -260,5 +277,7 @@ def render_rates(intent, book, destinations, remote_fees):
lines.append('请补充箱数、每箱重量和尺寸,继续核算计费重及超重超尺寸费用。')
if intent.get('mode')=='calculation':
lines.append('计算过程:各箱实重与体积重取大;海派每箱不足12KG按12KG计费,再按整票计费重选择重量档位,乘对应单价,并单列已知附加费。')
if intent.get('mode')=='recommend' and rate_selection=='fastest_cheapest':
lines.append('您更看重预算还是时效?提供目的仓码或邮编、交仓地后,可继续缩小方案。')
text='\n'.join(lines)
return translate_quote(text) if intent.get('english') else text
+39 -4
View File
@@ -13,6 +13,31 @@ WEIGHT_UNITS = r'kilograms?|kgs?|公斤|千克|grams?|克|g|tons?|吨|lbs?|磅'
WEIGHT = re.compile(r'(?<![\d.])(-?\d+(?:\.\d+)?)\s*('+WEIGHT_UNITS+r')(?![A-Za-z])',re.I)
MISSING_WEIGHT_UNIT = '请补充修改后重量的单位,例如KG或吨,我再按新重量核算。'
TOPIC_BOUNDARY = re.compile(r'新(?:的)?一(?:票|批)|另外|另一|换个话题|理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收')
TIMING_QUESTION = re.compile(r'时效|多久|几天|什么时候.*到|哪天.*到|何时.*到')
CLAIM_QUESTION = re.compile(r'理赔|索赔|赔(?:偿|付|钱|不赔|吗|嘛|么)|补偿|费用.*谁.*(?:承担|负责)')
def timing_with_claim(message):
if not CLAIM_QUESTION.search(message):return False
for part in re.split(r'[,,。!??;;]',message):
timing=TIMING_QUESTION.search(part)
claim=CLAIM_QUESTION.search(part)
if timing and (not claim or timing.start()<claim.start()):return True
return False
def quote_question_text(message):
"""Keep shipment conditions and timing when a separate clause asks about liability."""
if not timing_with_claim(message):return message
parts=[]
for part in re.split(r'[,,。!??;;]',message):
claim=CLAIM_QUESTION.search(part)
if claim:
if not TIMING_QUESTION.search(part):continue
part=part[:claim.start()]
if re.search(r'如果|要是|万一',part) and not TIMING_QUESTION.search(part):continue
parts.append(part)
return ','.join(parts)
def resumes_quote(message):
@@ -52,10 +77,17 @@ def kg(value,unit):
def request_selection(text):
if re.search(r'差价|相差|差多少|贵多少|便宜多少',text) and re.search(r'最快|最慢|快的|慢的',text):return 'speed_compare'
if re.search(r'最便宜|便宜的|便宜一?点|更便宜|优惠|折扣|打折|最省钱|最划算|最低价|cheapest|least expensive',text,re.I):return 'cheapest'
fastest=bool(re.search(r'最快|快的|更快|快一?点|fastest',text,re.I))
slowest=bool(re.search(r'最慢|慢的|slowest',text,re.I))
cheapest=bool(re.search(r'最便宜|便宜的|便宜一?点|更便宜|优惠|折扣|打折|最省钱|最划算|最低价|cheapest|least expensive',text,re.I))
fastest=fastest or bool(re.search(r'(?:时效|速度)优先|优先(?:时效|速度)|更看重(?:时效|速度)',text)
or re.fullmatch(r'(?:我选|选|选择)?(?:时效|速度)[。.!!??\s]*',text))
cheapest=cheapest or bool(re.search(r'(?:预算|运费|成本|价格)优先|优先(?:预算|运费|成本|价格)|更看重(?:预算|运费|成本|价格)',text)
or re.search(r'(?:想|要|希望|尽量|主要|优先|更在意)(?:能)?(?:省钱|便宜|节省运费|控制成本)',text)
or re.fullmatch(r'(?:我选|选|选择)?(?:预算|运费|成本|价格|省钱)[。.!!??\s]*',text))
if fastest and cheapest:return 'fastest_cheapest'
if re.search(r'差价|相差|差多少|贵多少|便宜多少',text) and (fastest or slowest):return 'speed_compare'
if cheapest:return 'cheapest'
if fastest and slowest:return 'speed_compare'
if fastest:return 'fastest'
if slowest:return 'slowest'
@@ -84,12 +116,14 @@ def request_mode(text):
if re.search(r'电池|带电|液体|粉末|食品|医疗|能(?:不能|发|走)|可以.*(?:发|走)',text):return 'acceptance'
if re.search(r'人民币|美元|美金|币种|currency|\b(?:RMB|CNY|USD)\b',text,re.I) and not WEIGHT.search(text):return 'currency'
if re.search(r'怎么(?:算|计算)|计算(?:过程|出来)|怎么算出来|calculation',text,re.I):return 'calculation'
if re.fullmatch(r'(?:我)?(?:选|选择|优先|更看重)?(?:时效|速度)(?:优先)?[。.!!??\s]*',text):return 'fastest'
if re.search(r'推荐|怎么走.*(?:好|合适)|走哪.*(?:好|合适)|选哪.*(?:好|合适)',text):return 'recommend'
if re.search(r'时效|多久|几天|多快(?:能|会)?到|什么时候(?:能|会|可以)?到|哪天|几号|何时(?:到|能到)|能收到',text) and not WEIGHT.search(text):return 'time'
return request_selection(text) or 'quote'
def parse_intent(message,warehouse_codes):
text=normalize(message)
text=normalize(quote_question_text(message))
from .quote_edits import condition_edits,quantity_adjustment,ADJUSTMENT_QUESTION,VOLUME_QUESTION
edits=condition_edits(text)
text=edits['text']
@@ -194,7 +228,7 @@ def parse_intent(message,warehouse_codes):
'quantity_uncertain':bool(change and change['kind']!='volume'),
'volume_pending':bool(change and change['kind']=='volume'),
'quantity_question':(VOLUME_QUESTION if change['kind']=='volume' else ADJUSTMENT_QUESTION) if change else None,
'errors':errors,'mode':mode,'selection':request_selection(text),
'errors':errors,'mode':mode,'selection':request_selection(text) or ('fastest_cheapest' if mode=='recommend' else None),
'pending_weight':pending_weight[1] if pending_weight else None,
'weight_unit':weight_unit[1] if weight_unit else None,
'clear_selection':bool(re.search(r'(?:全部|所有|各个?)(?:的)?(?:渠道|线路|方案)|(?:全部|所有)(?:报价|价格|运价)',text)),
@@ -207,6 +241,7 @@ def parse_intent(message,warehouse_codes):
def is_followup(message,intent):
message=quote_question_text(message)
if resumes_quote(message):return True
if re.search(r'和刚才一样|其他不变|其它不变|每箱不变',message):return True
if TOPIC_BOUNDARY.search(message):return False
+2
View File
@@ -19,9 +19,11 @@ 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:'),
('按相同区域、运输方式、计费单位及税费方式比较。','Options are compared within the same region, service kind, 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'),
('最便宜参考方案','Cheapest 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 '),
+2 -2
View File
@@ -11,8 +11,8 @@ def answer_us_fallback(message,conv_id):
if re.search(r'换个话题|另外问',message):return None
from .quote_edits import clean_conditions
current=clean_conditions(message)
active=bool(US.search(current))
if not active and shipping_region(current) is not None:return None
active=bool(US.search(current) or re.search(r'美森|\bEXX\b',current,re.I))
if not US.search(current) and shipping_region(current) is not None:return None
if not active and conv_id:
from .quote_service import previous_quote_intents
active=any(intent['country']=='US' for intent in previous_quote_intents(conv_id,set()))
+22 -10
View File
@@ -7,11 +7,11 @@ import sqlite3
from .db import connect
from .quote_data import load_book, postcode_fee
from .quote_engine import US, QUOTE_WORDS, parse_intent, is_followup, variants, render_rates
from .quote_intent import resumes_quote
from .quote_intent import resumes_quote,quote_question_text,timing_with_claim
from .customer_faq import is_acknowledgement
def previous_quote_intents(conv_id,codes,resume=False):
def previous_quote_intents(conv_id,codes,resume=False,include_other=False):
"""Read the current quote back to its topic boundary, without a turn cutoff."""
earlier_intents=[]
resume_updates=[]
@@ -28,10 +28,11 @@ def previous_quote_intents(conv_id,codes,resume=False):
resume_updates=earlier_intents[:]
resume=True
continue
boundary=bool(re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收|换个话题|另外问',previous))
quote_text=quote_question_text(previous)
boundary=bool(re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收|换个话题|另外问',quote_text))
quote_root=bool((earlier['country'] or earlier['warehouse'] or earlier.get('unknown_warehouses'))
and QUOTE_WORDS.search(previous))
if quote_root and not re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收',previous):
if quote_root and not re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收',quote_text):
boundary=False
boundary=boundary or bool(re.search(r'订单|运单|入仓码',previous) and not re.search(r'报价|海运|海派|海卡|空运',previous))
candidate=(QUOTE_WORDS.search(previous) or US.search(previous) or is_followup(previous,earlier) or
@@ -40,7 +41,9 @@ def previous_quote_intents(conv_id,codes,resume=False):
if not resume:break
earlier_intents=resume_updates[:]
continue
if earlier['country']=='other':break
if earlier['country']=='other':
if include_other:earlier_intents.append(earlier)
break
earlier_intents.append(earlier)
if not is_followup(previous,earlier):break
finally:
@@ -54,7 +57,9 @@ def intent_with_context(message, conv_id, book):
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=previous_quote_intents(conv_id,codes,resume=resumes_quote(message)) if is_followup(message,intent) else []
named_us_service=bool(re.search(r'美森|\bEXX\b',intent['text'],re.I)) and intent['country'] is None and not intent['warehouse']
earlier_intents=(previous_quote_intents(conv_id,codes,resume=resumes_quote(message),include_other=named_us_service)
if is_followup(message,intent) else [])
if intent.get('quantity_change') and not earlier_intents:
intent['errors'].append(intent['quantity_question'])
if intent.get('metal_weight_update') and not earlier_intents:
@@ -66,13 +71,13 @@ def intent_with_context(message, conv_id, book):
for update in list(reversed(earlier_intents[:-1]))+[intent]:
state=merge_intent(update,state)
intent=state
if (intent['warehouse'] or intent.get('unknown_warehouses')) and intent['country'] is None:
if (intent['warehouse'] or intent.get('unknown_warehouses') or named_us_service) and intent['country'] is None:
intent['country']='US'
return intent
def answer_quote(message, conv_id):
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', message):
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', quote_question_text(message)):
return None
# Avoid a new read for ordinary chat; short followups need their local context.
from .shipping_faq import preparation_topic
@@ -85,6 +90,10 @@ def answer_quote(message, conv_id):
return None
intent = intent_with_context(message, conv_id, book)
if intent['country'] != 'US':
if intent['country']=='other' and re.search(r'美森|\bEXX\b',message,re.I) and not US.search(message):
return {'t':'您前面咨询的是其他目的地,美森和EXX在当前资料中是美国渠道。请确认这票是否发往美国,再继续核对时效和价格。'}
if intent['country'] is None and intent.get('mode')=='recommend':
return {'t':'请提供目的国家、货物重量和目的仓码或邮编,我再比较时效优先与运费优先的方案。'}
if intent.get('quantity_change'):
if intent['quantity_change']['kind']=='volume':
return {'t':'请先提供原来的总体积和目的国家、目的仓,再说明增减量。'}
@@ -107,9 +116,9 @@ def answer_quote(message, conv_id):
if intent.get('mode')=='clarify_route':
return {'t':'请指定方案名称,例如美森正班或普船,或说“最快”“最慢”“最便宜”,我按当前货物条件继续核算。'}
if intent.get('mode')=='difference':
if intent.get('selection') not in ('fastest','slowest','speed_compare'):
if intent.get('selection') not in ('fastest','slowest','speed_compare','fastest_cheapest'):
return {'t':'请明确要比较的方案,可直接问“最快和最慢相差多少”,我按当前货物条件核算差额。'}
intent['selection']='speed_compare'
if intent['selection']!='fastest_cheapest':intent['selection']='speed_compare'
if intent.get('unknown_warehouses'):
return {'t':'未找到目的仓码 '+ '、'.join(intent['unknown_warehouses'])+',请核对仓码,或提供5位美国邮编继续查询。'}
if not (QUOTE_WORDS.search(message) or is_followup(message,intent) or
@@ -144,6 +153,9 @@ def answer_quote(message, conv_id):
return {'t':f'当前海派资料不接收邮编 {postal} 的地址,请提供其他目的仓码或联系人工确认可用线路。'}
fees.append(postcode_fee(book['id'],postal) if postal else None)
reply=render_rates(intent,book,destinations,fees)
if timing_with_claim(message):
from .quote_answers import timing_liability
reply+='\n'+timing_liability(intent,book,destinations)
if intent.get('mode')=='total':
reply+='\n以上为已提供条件下可计算的运费,各方案按所列包税/不包税方式分别核算;目的仓、箱规及未确定附加费需核实后才能确认最终应付总价。'
if intent.get('mode')=='cheapest' and re.search(r'便宜一?点|折扣|优惠|打折',message):
+10 -1
View File
@@ -202,7 +202,16 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.to-bottom { display: none; position: absolute; right: 24px; bottom: calc(var(--composer-height, 150px) + 12px); z-index: 4; width: 38px; height: 38px; border-radius: 50%; border: 1px solid var(--border); background: var(--surface); color: var(--secondary); box-shadow: var(--shadow); }
.to-bottom.show { display: block; }
.to-bottom:hover { color: var(--brand-text); border-color: var(--brand); }
.typing::after { content: " ···"; color: var(--brand-text); }
.robot-message.typing { min-width: min(240px, 94%); }
.reply-waiting::before { content: ""; display: inline-block; width: 12px; height: 12px; margin-right: 9px; border: 2px solid var(--border); border-top-color: var(--brand-text); border-radius: 50%; vertical-align: -2px; animation: reply-spin .8s linear infinite; }
.reply-waiting::after { content: "…"; color: var(--brand-text); animation: reply-pulse 1s ease-in-out infinite; }
.reply-ready { animation: reply-enter .18s ease-out; }
@keyframes reply-pulse { 50% { opacity: .3; } }
@keyframes reply-spin { to { transform: rotate(360deg); } }
@keyframes reply-enter { from { opacity: .4; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) {
.reply-waiting::after, .reply-waiting::before, .reply-ready { animation: none; }
}
.sidebar-backdrop { display: none; }
.attachment-preview { display: flex; align-items: center; gap: 12px; padding: 10px 12px; margin-bottom: 9px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-soft); color: var(--text); }
.attachment-preview img { height: 42px; width: 42px; object-fit: cover; border: 1px solid var(--border); border-radius: 7px; }
+55 -31
View File
@@ -266,7 +266,7 @@ const I18N = {
confirmDel: "确定要删除这条历史对话吗?",
emptySearch: "没有匹配的历史对话",
emptyHistory: "暂无历史对话",
thinking: "正在思考中...",
thinking: "正在处理您的问题",
busyRetry: "服务繁忙,请稍后重试",
sessionFail: "会话创建失败,请确认服务已启动",
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。",
@@ -383,7 +383,7 @@ const I18N = {
confirmDel: "Delete this conversation?",
emptySearch: "No matching conversations",
emptyHistory: "No conversations yet",
thinking: "Thinking...",
thinking: "Preparing your reply",
busyRetry: "Service busy, please try again later",
sessionFail: "Failed to create session, please check the service is running",
autoWelcome: "Welcome to Baiyue Logistics. Ask about rates, transit times or human support.",
@@ -1706,13 +1706,30 @@ async function doSend(retryText, showQuestion){
}
const aiBubble = addMsg(t("thinking"), false);
aiBubble.classList.add("typing");
aiBubble.textContent = t('thinking');
aiBubble.classList.add("typing", "reply-waiting");
aiBubble.setAttribute('aria-busy', 'true');
activeBubble = aiBubble;
setBusy(true);
let es = null;
let firstChunk = true;
let receivedDone = false;
let receivedContent = false;
let settled = false; // 已经结束(成功或失败),用它区分"正常关闭"和"真断开"
const reply = ChatReply.create({
shouldAnimate:function(){
return !document.hidden && !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
},
onText:function(raw){
if(settled || version !== conversationVersion) return;
aiBubble.classList.remove('reply-waiting');
aiBubble.dataset.raw = raw;
aiBubble.innerHTML = parseBold(raw);
scrollChatToEnd();
},
onComplete:finish,
onError:function(){ fail(t('networkFail')); },
});
const firstTimer = setTimeout(function(){ fail("模型响应超时,请稍后重试"); }, 90000);
const totalTimer = setTimeout(function(){ fail("生成时间过长,已中断,请重试"); }, 120000);
@@ -1729,11 +1746,13 @@ async function doSend(retryText, showQuestion){
}
function finish(){
if(settled) return;
if(settled || version !== conversationVersion) return;
settled = true;
reply.cancel();
cleanup();
setBusy(false);
aiBubble.classList.remove("typing");
aiBubble.classList.remove("typing", "reply-waiting");
aiBubble.setAttribute('aria-busy', 'false');
if(activeBubble === aiBubble) activeBubble = null;
let raw = aiBubble.dataset.raw || "";
@@ -1755,9 +1774,13 @@ async function doSend(retryText, showQuestion){
function fail(text){
if(settled) return;
try{ reply.flush(); }catch(error){}
settled = true;
reply.cancel();
cleanup();
setBusy(false);
aiBubble.classList.remove('reply-waiting');
aiBubble.setAttribute('aria-busy', 'false');
if(activeBubble === aiBubble) activeBubble = null;
renderError(aiBubble, text || t("busyRetry"), function(){
return retryAnswer(aiBubble, fullContent);
@@ -1768,9 +1791,11 @@ async function doSend(retryText, showQuestion){
function stop(){
if(settled) return;
settled = true;
reply.cancel();
cleanup();
setBusy(false);
aiBubble.classList.remove("typing");
aiBubble.classList.remove("typing", "reply-waiting");
aiBubble.setAttribute('aria-busy', 'false');
if(activeBubble === aiBubble) activeBubble = null;
const raw = aiBubble.dataset.raw || "";
if(raw){
@@ -1788,7 +1813,7 @@ async function doSend(retryText, showQuestion){
cancelActive = stop;
function handleFrame(dataText){
if(settled || version !== conversationVersion) return;
if(settled || receivedDone || version !== conversationVersion) return;
let obj;
try{
obj = JSON.parse(dataText);
@@ -1799,38 +1824,37 @@ async function doSend(retryText, showQuestion){
if(obj.e){ fail(obj.e); return; }
if(obj.login_required) aiBubble.dataset.loginRequired = '1';
if(obj.card){
receivedContent = true;
clearTimeout(firstTimer);
aiBubble.classList.add("track-card");
fillTrackCard(aiBubble, obj.card);
aiBubble.dataset.raw = obj.card.kind === 'order' ? obj.card.summary : "运单号 " + obj.card.no + "(" + (obj.card.status || "") + ")\n" +
(obj.card.nodes || []).map(function(n){ return n.time + " " + n.text + " " + (n.place || ""); }).join("\n");
firstChunk = false;
scrollChatToEnd();
reply.card(function(){
if(settled || version !== conversationVersion) return;
aiBubble.classList.remove('reply-waiting');
aiBubble.classList.add('track-card', 'reply-ready');
fillTrackCard(aiBubble, obj.card);
aiBubble.dataset.raw = obj.card.kind === 'order' ? obj.card.summary : "运单号 " + obj.card.no + "(" + (obj.card.status || "") + ")\n" +
(obj.card.nodes || []).map(function(n){ return n.time + " " + n.text + " " + (n.place || ""); }).join("\n");
scrollChatToEnd();
});
return;
}
if(obj.replace){
receivedContent = true;
clearTimeout(firstTimer);
aiBubble.dataset.raw = obj.replace;
aiBubble.innerHTML = parseBold(obj.replace);
scrollChatToEnd();
reply.replace(obj.replace);
return;
}
if(obj.suggest){
aiBubble.dataset.suggest = JSON.stringify(obj.suggest);
return;
}
if(obj.d){ finish(); return; }
if(typeof obj.t !== "string") return;
clearTimeout(firstTimer);
if(firstChunk){
aiBubble.innerHTML = "";
aiBubble.dataset.raw = "";
firstChunk = false;
if(obj.d){
if(!receivedContent){ fail(t('networkFail')); return; }
receivedDone = true; cleanup(); reply.complete(); return;
}
const raw = (aiBubble.dataset.raw || "") + obj.t;
aiBubble.dataset.raw = raw;
aiBubble.innerHTML = parseBold(raw);
scrollChatToEnd();
if(typeof obj.t !== "string" || !obj.t) return;
receivedContent = true;
clearTimeout(firstTimer);
reply.append(obj.t);
}
// 用 POST + fetch 流式读取:问题放在请求体里,不受 URL 长度限制
@@ -1877,18 +1901,18 @@ async function doSend(retryText, showQuestion){
if(r.done){
buffer += decoder.decode();
drain(true);
if(!settled) fail(t("networkFail"));
if(!settled && !receivedDone) fail(t("networkFail"));
return;
}
buffer += decoder.decode(r.value, {stream: true});
drain(false);
if(!settled) return pump();
if(!settled && !receivedDone) return pump();
});
}
return pump();
}).catch(function(err){
if(err && err.name === "AbortError") return; // 用户主动停止
if(settled) return;
if(settled || receivedDone) return;
fail(t("networkFail"));
});
return true;
+81
View File
@@ -0,0 +1,81 @@
// Pace already received text while preserving network completion and cancellation.
var ChatReply = (function(){
function create(options){
const now = options.now || Date.now;
const readyAt = now() + 300;
const segmenter = typeof Intl !== 'undefined' && Intl.Segmenter
? new Intl.Segmenter(undefined, {granularity:'grapheme'}) : null;
let text = '', characters = [], visible = 0;
let startedAt = null, timer = null, card = null;
let complete = false, closed = false;
function clearTimer(){
if(timer !== null) clearTimeout(timer);
timer = null;
}
function schedule(delay){
if(timer === null && !closed) timer = setTimeout(function(){
try{ draw(false); }
catch(error){ closed = true; clearTimer(); options.onError(error); }
}, delay);
}
function draw(force){
clearTimer();
if(closed) return;
const animate = !force && options.shouldAnimate();
if(animate && now() < readyAt){ schedule(readyAt - now()); return; }
if(card){
const show = card;
card = null;
show();
}
if(visible < characters.length){
if(startedAt === null) startedAt = now();
const elapsed = Math.max(0, now() - startedAt);
const rate = Math.max(45, characters.length / 4);
visible = animate ? Math.min(characters.length, Math.max(visible + 1, Math.floor(elapsed * rate / 1000))) : characters.length;
options.onText(characters.slice(0, visible).join(''));
}
if(visible < characters.length){ schedule(16); return; }
if(complete){
closed = true;
options.onComplete();
}
}
function update(){
if(closed) return;
if(!options.shouldAnimate()) draw(true);
else schedule(Math.max(0, readyAt - now()));
}
function setText(value){
text = value;
characters = segmenter ? Array.from(segmenter.segment(text), item => item.segment) : Array.from(text);
}
return {
append:function(value){
if(closed) return;
card = null;
setText(text + value);
update();
},
replace:function(value){
if(closed) return;
card = null;
setText(value);
visible = 0;
update();
},
card:function(show){
if(closed) return;
setText('');
visible = 0;
card = show;
update();
},
complete:function(){ complete = true; update(); },
flush:function(){ draw(true); },
cancel:function(){ closed = true; clearTimer(); },
};
}
return {create:create};
})();
+1
View File
@@ -103,6 +103,7 @@
<script src="{{ url_for('static', filename='js/order_lookup.js') }}"></script>
<script src="{{ url_for('static', filename='js/drafts.js') }}"></script>
<script src="{{ url_for('static', filename='js/voice.js') }}"></script>
<script src="{{ url_for('static', filename='js/reply.js') }}"></script>
<script src="{{ url_for('static', filename='js/chat.js') }}"></script>
</body>
</html>
+127 -8
View File
@@ -15,7 +15,7 @@ async function customerPage(pageOptions = {}){
get length(){return Object.keys(values).length;}
};}
const elements = new Map(), listeners = {}, windowListeners = {}, viewportListeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {};
let intervalId = 0, timerId = 0, nextConvId = 1;
let intervalId = 0, timerId = 0, nextConvId = 1, clock = 0;
let voiceOptions;
class Element {
constructor(){
@@ -24,12 +24,12 @@ async function customerPage(pageOptions = {}){
this.listeners = {}; this.focusCalls = []; this.isConnected = true;
this.classList = {
contains: name => this.className.split(' ').includes(name),
add: name => { this.className += ' '+name; },
remove: name => { this.className = this.className.split(' ').filter(value => value !== name).join(' '); },
add: (...names) => { this.className += ' '+names.join(' '); },
remove: (...names) => { this.className = this.className.split(' ').filter(value => !names.includes(value)).join(' '); },
toggle: (name, on) => { this.classList.remove(name); if(on) this.classList.add(name); },
};
}
set innerHTML(value){ this.html = value; this.children = []; }
set innerHTML(value){ this.text = ''; this.html = value; this.children = []; }
get innerHTML(){ return this.html || ''; }
set textContent(value){ this.text = value; this.html = ''; this.children = []; }
get textContent(){ return (this.text || this.innerText || this.html || '')+this.children.map(child => child.textContent).join(''); }
@@ -74,7 +74,10 @@ async function customerPage(pageOptions = {}){
execCommand: () => pageOptions.copyResult !== false,
addEventListener: (name, callback) => { const previous=listeners[name]; listeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
};
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); } }
class TestDate extends Date {
constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); }
static now(){ return pageOptions.reducedMotion === false ? clock : Date.now(); }
}
let state = {status:'active', messages:[]};
let customerState=pageOptions.customer || null;
const context = vm.createContext({
@@ -84,13 +87,14 @@ async function customerPage(pageOptions = {}){
localStorage:testStorage(storageValues),sessionStorage:testStorage(tabValues),
window:{innerWidth:pageOptions.width || 1200, innerHeight:pageOptions.viewportHeight || pageOptions.height || 800,
addEventListener:(name,callback) => { (windowListeners[name] ||= []).push(callback); },
matchMedia:query => ({matches:context.window.innerWidth <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1])}),
matchMedia:query => ({matches:query.includes('prefers-reduced-motion') ? pageOptions.reducedMotion !== false
: context.window.innerWidth <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1])}),
visualViewport:pageOptions.viewportHeight ? {height:pageOptions.viewportHeight,scale:1,offsetTop:0,
addEventListener:(name,callback) => { (viewportListeners[name] ||= []).push(callback); }} : undefined,
prompt:() => pageOptions.offlineMessage || null},
setInterval: callback => { const id = ++intervalId; intervals.set(id, callback); return id; },
clearInterval:id => intervals.delete(id),
setTimeout:(callback, delay) => { const id=++timerId; timers.set(id,{callback,delay}); return id; },
setTimeout:(callback, delay) => { const id=++timerId; timers.set(id,{callback,delay,at:clock+delay}); return id; },
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)});
@@ -123,7 +127,7 @@ async function customerPage(pageOptions = {}){
return {ok:true, json:async () => data};
},
});
for(const file of ['support.js', 'visitor.js', 'customer.js', 'order_lookup.js', 'drafts.js', 'voice.js', 'chat.js']){
for(const file of ['support.js', 'visitor.js', 'customer.js', 'order_lookup.js', 'drafts.js', 'voice.js', 'reply.js', 'chat.js']){
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file});
if(file === 'voice.js'){
const createVoice=context.ChatVoice.create;
@@ -156,6 +160,17 @@ async function customerPage(pageOptions = {}){
pageHide:async () => { (windowListeners.pagehide || []).forEach(callback => callback()); await flush(); },
setState:value => { state=value; },
runTimers:async delay => { for(const [id, timer] of Array.from(timers)) if(timer.delay === delay){ timers.delete(id); timer.callback(); } await flush(); },
advanceTimers:async milliseconds => {
const end=clock+milliseconds;
let count=0;
while(true){
const next=Array.from(timers).filter(([,timer])=>timer.at<=end).sort((a,b)=>a[1].at-b[1].at)[0];
if(!next) break;
assert.ok(++count<10000,'Timer queue must settle');
clock=next[1].at;timers.delete(next[0]);next[1].callback();await flush();
}
clock=end;await flush();
},
tick:async () => { Array.from(intervals.values()).forEach(callback => callback()); await flush(); },
click:async button => { await button.click(); await flush(); },
};
@@ -281,6 +296,110 @@ test('rate limit responses show the server message and allow retry', async () =>
assert.ok(app.element('chatDom').querySelector('.retry-btn'));
});
test('a local completed reply keeps waiting feedback then progressively renders before enabling actions', async () => {
const app=await customerPage({human:false,reducedMotion:false});
await app.context.window.fillInput('查询报价');
await flush();
const bubble=app.element('chatDom').querySelectorAll('.robot-message').at(-1);
assert.match(bubble.textContent,/正在处理/);
assert.equal(app.context.window.__busy,true);
assert.equal(bubble.querySelector('.msg-actions'),null);
await app.advanceTimers(299);
assert.match(bubble.textContent,/正在处理/);
await app.advanceTimers(1);
assert.ok(bubble.dataset.raw.length>0 && bubble.dataset.raw.length<'测试 AI 回复'.length);
assert.equal(app.context.window.__busy,true);
await app.advanceTimers(2000);
assert.equal(bubble.dataset.raw,'测试 AI 回复');
assert.equal(app.context.window.__busy,false);
assert.ok(bubble.querySelector('.msg-actions'));
assert.equal(bubble.getAttribute('aria-busy'),'false');
assert.equal(bubble.querySelector('.retry-btn'),null);
});
test('stopping during the waiting stage cancels pending presentation', async () => {
const app=await customerPage({human:false,reducedMotion:false});
await app.context.window.fillInput('查询报价');
await flush();
const bubble=app.element('chatDom').querySelectorAll('.robot-message').at(-1);
await app.click(app.element('send-button'));
const stopped=bubble.textContent;
await app.advanceTimers(10000);
assert.equal(bubble.textContent,stopped);
assert.equal(bubble.dataset.raw,undefined);
assert.equal(app.context.window.__busy,false);
});
test('stopping mid-reveal freezes the displayed prefix and preserves the next draft', async () => {
const app=await customerPage({human:false,reducedMotion:false});
const answer='完整的报价内容。'.repeat(100);
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({t:answer})+'\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput('查询报价');
await flush();
await app.advanceTimers(600);
const bubble=app.element('chatDom').querySelectorAll('.robot-message').at(-1);
const prefix=bubble.dataset.raw;
assert.ok(prefix.length<answer.length);
app.element('input-text').value='下一条问题';
await app.click(app.element('send-button'));
await app.advanceTimers(10000);
assert.equal(bubble.dataset.raw,prefix);
assert.equal(app.element('input-text').value,'下一条问题');
assert.equal(app.context.window.__busy,false);
});
test('switching conversations cancels a response whose network stream already completed', async () => {
const app=await customerPage({human:false,reducedMotion:false});
app.hooks['/stream_chat']=()=>streamResponse(['data: {"t":"旧对话的排队回复"}\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput('查询报价');
await flush();
await app.click(app.element('.new-chat-btn'));
await app.advanceTimers(10000);
assert.doesNotMatch(app.element('chatDom').textContent,/旧对话的排队回复/);
assert.equal(app.context.window.__busy,false);
});
test('premature stream closure flushes received text and cancels its animation before retry', async () => {
const app=await customerPage({human:false,reducedMotion:false});
app.hooks['/stream_chat']=()=>streamResponse(['data: {"t":"已经收到的部分回答"}\n\n']);
await app.context.window.fillInput('查询报价');
await flush();
const bubble=app.element('chatDom').querySelectorAll('.robot-message').at(-1);
assert.match(bubble.textContent,/已经收到的部分回答/);
assert.ok(bubble.querySelector('.retry-btn'));
await app.advanceTimers(10000);
assert.equal(app.context.window.__busy,false);
assert.equal(bubble.dataset.raw,'已经收到的部分回答');
});
test('an empty completion releases the waiting indicator and offers retry', async () => {
const app=await customerPage({human:false,reducedMotion:false});
app.hooks['/stream_chat']=()=>streamResponse(['data: {"t":""}\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput('查询报价');
await flush();
const bubble=app.element('chatDom').querySelectorAll('.robot-message').at(-1);
assert.ok(bubble.querySelector('.retry-btn'));
assert.equal(bubble.classList.contains('reply-waiting'),false);
assert.equal(app.context.window.__busy,false);
await app.advanceTimers(10000);
assert.equal(app.context.window.__busy,false);
});
test('a structured order card is shown after waiting and completes without a false disconnect', async () => {
const app=await customerPage({human:false,reducedMotion:false});
const card={kind:'order',no:'ORDER12345',status:'履约中',fields:[],nodes:[],summary:'入仓码 IN12345'};
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput('IN12345');
await flush();
const bubble=app.element('chatDom').querySelectorAll('.robot-message').at(-1);
assert.match(bubble.textContent,/正在处理/);
await app.advanceTimers(300);
assert.ok(bubble.classList.contains('track-card'));
assert.equal(bubble.dataset.raw,card.summary);
assert.equal(app.context.window.__busy,false);
assert.equal(bubble.querySelector('.retry-btn'),null);
});
test('a stalled conversation creation times out and can be retried without losing the draft', async () => {
const app=await customerPage({human:false});
app.hooks['/api/new_chat']=options => new Promise((resolve,reject) => {
+174
View File
@@ -0,0 +1,174 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function playback(options = {}){
let clock = 0, nextId = 0, animate = options.animate !== false, completed = 0;
const timers = new Map(), text = [], errors = [];
const context = vm.createContext({
Intl,
setTimeout(callback, delay){ const id = ++nextId; timers.set(id, {callback, at:clock + delay}); return id; },
clearTimeout(id){ timers.delete(id); },
});
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js/reply.js'), 'utf8'), context);
const reply = context.ChatReply.create({
now:() => clock,
shouldAnimate:() => animate,
onText:value => text.push(value),
onComplete:() => { completed++; },
onError:error => errors.push(error),
});
function advance(milliseconds){
const end = clock + milliseconds;
let count = 0;
while(true){
const next = Array.from(timers).filter(([,timer]) => timer.at <= end).sort((a,b) => a[1].at - b[1].at)[0];
if(!next) break;
assert.ok(++count < 10000, 'Timer queue must settle');
clock = next[1].at;
timers.delete(next[0]);
next[1].callback();
}
clock = end;
}
return {reply, advance, text, errors, timers, completed:() => completed, reduceMotion:() => { animate = false; }};
}
test('a complete local response waits briefly and reveals text before finishing', () => {
const app = playback();
const answer = '这是来自数据库的报价回复。'.repeat(10);
app.reply.append(answer);
app.reply.complete();
app.advance(299);
assert.deepEqual(app.text, []);
app.advance(1);
assert.ok(app.text.at(-1).length < answer.length);
assert.equal(app.completed(), 0);
app.advance(4200);
assert.equal(app.text.at(-1), answer);
assert.equal(app.completed(), 1);
assert.equal(app.timers.size, 0);
});
test('real streaming chunks keep their full content and finish once', () => {
const app = playback();
app.advance(1000);
app.reply.append('第一段');
app.advance(200);
app.reply.append(',接着第二段。');
app.reply.complete();
app.advance(4500);
assert.equal(app.text.at(-1), '第一段,接着第二段。');
assert.equal(app.completed(), 1);
app.reply.complete();
app.advance(100);
assert.equal(app.completed(), 1);
});
test('replacement supersedes both pending text and a previously displayed prefix', () => {
const app = playback();
app.reply.append('旧的详细报价'.repeat(20));
app.advance(400);
assert.ok(app.text.length);
app.reply.replace('新的简明报价');
app.reply.complete();
app.advance(4500);
assert.equal(app.text.at(-1), '新的简明报价');
assert.equal(app.completed(), 1);
});
test('cancellation before the first reveal prevents late text and completion', () => {
const app = playback();
app.reply.append('等待中的回复');
app.reply.complete();
app.reply.cancel();
app.advance(10000);
assert.deepEqual(app.text, []);
assert.equal(app.completed(), 0);
assert.equal(app.timers.size, 0);
});
test('cancellation freezes a displayed prefix and ignores later chunks', () => {
const app = playback();
app.reply.append('已经收到的长回复'.repeat(100));
app.advance(500);
const prefix = app.text.at(-1);
app.reply.cancel();
app.reply.append('迟到的内容');
app.reply.complete();
app.advance(10000);
assert.equal(app.text.at(-1), prefix);
assert.equal(app.completed(), 0);
});
test('cards are shown intact after the waiting feedback', () => {
const app = playback();
let shown = 0;
app.reply.card(() => { shown++; });
app.reply.complete();
app.advance(299);
assert.equal(shown, 0);
app.advance(1);
assert.equal(shown, 1);
assert.equal(app.completed(), 1);
});
test('reduced motion immediately renders text and cards', () => {
const app = playback({animate:false});
app.reply.append('快速显示的完整回复');
assert.equal(app.text.at(-1), '快速显示的完整回复');
app.reply.complete();
assert.equal(app.completed(), 1);
assert.equal(app.timers.size, 0);
const card = playback({animate:false});
let shown = false;
card.reply.card(() => { shown = true; });
card.reply.complete();
assert.equal(shown, true);
assert.equal(card.completed(), 1);
});
test('a long answer has a bounded presentation duration', () => {
const app = playback();
const answer = '运费和时效。'.repeat(4000);
app.reply.append(answer);
app.reply.complete();
app.advance(4320);
assert.equal(app.text.at(-1), answer);
assert.equal(app.completed(), 1);
});
test('grapheme clusters remain intact during progressive display', () => {
const app = playback();
app.reply.append('👨‍👩‍👧‍👦您好,这是完整字符。');
app.advance(300);
assert.equal(app.text[0], '👨‍👩‍👧‍👦');
app.reply.complete();
app.advance(4500);
assert.equal(app.text.at(-1), '👨‍👩‍👧‍👦您好,这是完整字符。');
});
test('a hidden or reduced-motion page can flush all received text', () => {
const app = playback();
const answer = '完整的报价内容'.repeat(50);
app.reply.append(answer);
app.reply.complete();
app.advance(350);
app.reduceMotion();
app.reply.flush();
assert.equal(app.text.at(-1), answer);
assert.equal(app.completed(), 1);
assert.equal(app.timers.size, 0);
});
test('an asynchronous rendering failure cancels the queue and reports once', () => {
const app = playback();
app.reply.card(() => { throw new Error('Cannot render card'); });
app.reply.complete();
app.advance(500);
assert.equal(app.errors.length, 1);
assert.equal(app.completed(), 0);
assert.equal(app.timers.size, 0);
});
+9
View File
@@ -8,6 +8,7 @@ from pathlib import Path
import subprocess
import sys
import tempfile
import time
import unittest
from unittest.mock import patch
@@ -349,6 +350,14 @@ class ApiTests(unittest.TestCase):
with self.client.get('/static/fa.jpg') as image:
self.assertEqual(image.status_code, 200)
def test_600_request_budget_accepts_the_last_request_and_blocks_the_next(self):
self.app.config['RATE_LIMIT_PER_MIN'] = 600
now = time.time()
self.app.extensions['rate_hits']['127.0.0.1'] = [now] * 599
with patch('logistics_chat.hooks.time.time', return_value=now):
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
self.assertEqual(self.client.get('/api/get_history').status_code, 429)
def test_attachment_roundtrip_and_rejection(self):
response = self.client.post('/api/upload', data={'file': (io.BytesIO(b'hello'), 'hello.txt')})
self.assertEqual(response.status_code, 200)
+31
View File
@@ -21,6 +21,7 @@ class ConfigurationTests(unittest.TestCase):
environment.start()
self.addCleanup(environment.stop)
os.environ.pop('DEEPSEEK_API_KEY', None)
os.environ.pop('RATE_LIMIT_PER_MIN', None)
def write_env(self, text):
(self.root / '.env').write_text(text, encoding='utf-8')
@@ -98,6 +99,36 @@ class ConfigurationTests(unittest.TestCase):
os.environ['ORDER_DATA_TENANT_ID'] = 'tenant-environment'
self.assertEqual(self.application().config['ORDER_DATA_TENANT_ID'], 'tenant-environment')
def test_rate_limit_defaults_to_600_requests_per_minute(self):
self.assertEqual(self.application().config['RATE_LIMIT_PER_MIN'], 600)
def test_rate_limit_can_be_configured_in_the_repository_env_file(self):
self.write_env('RATE_LIMIT_PER_MIN=" 1200 " # Local request budget\n')
self.assertEqual(self.application().config['RATE_LIMIT_PER_MIN'], 1200)
self.assertNotIn('RATE_LIMIT_PER_MIN', os.environ)
def test_rate_limit_environment_and_factory_overrides_take_priority(self):
self.write_env('RATE_LIMIT_PER_MIN=1200\n')
os.environ['RATE_LIMIT_PER_MIN'] = '600'
self.assertEqual(self.application().config['RATE_LIMIT_PER_MIN'], 600)
self.assertEqual(self.application(RATE_LIMIT_PER_MIN=900).config['RATE_LIMIT_PER_MIN'], 900)
def test_rate_limit_rejects_invalid_file_values(self):
for line in ('RATE_LIMIT_PER_MIN=', 'RATE_LIMIT_PER_MIN', 'RATE_LIMIT_PER_MIN=0',
'RATE_LIMIT_PER_MIN=-1', 'RATE_LIMIT_PER_MIN=600.5', 'RATE_LIMIT_PER_MIN=invalid'):
with self.subTest(line=line):
self.write_env(line + '\n')
with self.assertRaisesRegex(ValueError, 'RATE_LIMIT_PER_MIN must be a positive integer'):
self.application()
def test_rate_limit_rejects_invalid_environment_values_over_a_valid_file(self):
self.write_env('RATE_LIMIT_PER_MIN=600\n')
for value in ('', '0', '-1', '600.5', 'invalid'):
with self.subTest(value=value):
os.environ['RATE_LIMIT_PER_MIN'] = value
with self.assertRaisesRegex(ValueError, 'RATE_LIMIT_PER_MIN must be a positive integer'):
self.application()
if __name__ == '__main__':
unittest.main()
+1 -1
View File
@@ -11,7 +11,7 @@ class FrontendTests(unittest.TestCase):
def test_reply_workflows(self):
root = Path(__file__).resolve().parents[1]
result = subprocess.run(
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs', 'tests/frontend/drafts.test.cjs'], cwd=root,
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs', 'tests/frontend/drafts.test.cjs', 'tests/frontend/reply.test.cjs'], cwd=root,
text=True, encoding='utf-8', capture_output=True, timeout=30,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+14
View File
@@ -196,6 +196,20 @@ class OrderStatisticsTests(unittest.TestCase):
self.assertNotIn('请提供',text)
self.assertEqual(self.ask('重量呢',cid)['card']['no'],first)
def test_short_choice_confirmation_preserves_the_selected_order_and_navigation(self):
self.login()
cid=self.client.post('/api/new_chat').json['id']
self.ask('我有多少异常订单',cid)
first=self.ask('第一个呢',cid)['card']['no']
second=self.ask('下一票呢',cid)['card']['no']
self.ask('就这个',cid)
with self.app.app_context():
connection=db.connect()
reason=connection.execute('SELECT exception_reason FROM order_test_data WHERE order_no=?',(second,)).fetchone()[0]
connection.close()
self.assertIn(reason,self.ask('异常原因呢',cid)['t'])
self.assertEqual(self.ask('上一票呢',cid)['card']['no'],first)
def test_empty_or_out_of_range_ordinals_never_choose_an_unshown_order(self):
self.login();cid=self.client.post('/api/new_chat').json['id']
self.ask('我有多少异常订单',cid)
+232
View File
@@ -0,0 +1,232 @@
"""Compound quote questions retain shipment facts and local service terms."""
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_intent import timing_with_claim
from test_quote_timing import timing_snapshot
from test_quotes import frames
class QuoteConversationTests(unittest.TestCase):
def setUp(self):
folder=tempfile.TemporaryDirectory()
self.addCleanup(folder.cleanup)
root=Path(folder.name)
self.app=create_app({'TESTING':True,'DATABASE':root/'history.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':'9'*64})
self.cid=self.client.post('/api/new_chat').json['id']
with self.app.app_context():
connection=db.connect()
import_snapshot(connection,timing_snapshot(),activate=True)
connection.commit()
connection.close()
def ask(self,question):
with patch('logistics_chat.ai_client.post_completion') as model:
events=frames(self.client.post('/stream_chat',json={'msg':question,'conv_id':self.cid}))
model.assert_not_called()
self.assertEqual(events[-1],{'d':1},events)
self.assertFalse(any(event.get('e') for event in events),events)
return events[0]['t']
def test_fastest_and_cheapest_in_one_question_both_receive_prices(self):
self.ask('一票3000KG发美国,义乌美东,价格怎么样')
text=self.ask('最快的多少钱,最便宜的是什么')
fast,cheap=text.split('最便宜参考方案')
self.assertIn('最快参考方案',fast)
self.assertIn('美森正班海派',fast)
self.assertIn('3000KG = 30000元',fast)
self.assertIn('普船海派',cheap)
self.assertIn('3000KG = 18000元',cheap)
self.assertNotIn('华南',text)
def test_recommendation_keeps_quantity_and_can_be_followed_by_a_quantity_edit(self):
self.ask('美国3000KG义乌美东海派报价')
text=self.ask('请帮我推荐一下,怎么走比较好')
self.assertIn('最快参考方案',text)
self.assertIn('最便宜参考方案',text)
self.assertIn('预算还是时效',text)
self.assertIn('3000KG = 18000元',text)
text=self.ask('再加100KG,其他不变')
self.assertIn('3100KG = 18600元',text)
self.assertIn('3100KG = 31000元',text)
self.assertNotIn('华南',text)
def test_recommendation_respects_an_explicit_budget_preference(self):
self.ask('美国3000KG义乌美东海派报价')
text=self.ask('推荐最便宜的')
self.assertIn('优先运费',text)
self.assertIn('普船海派',text)
self.assertNotIn('美森正班海派',text)
self.assertNotIn('预算还是时效',text)
def test_answers_to_the_recommendation_preference_question_select_the_route(self):
self.ask('美国3000KG义乌美东海派报价')
self.ask('请帮我推荐一下')
text=self.ask('预算优先')
self.assertIn('普船海派',text)
self.assertIn('3000KG = 18000元',text)
self.assertNotIn('美森正班海派',text)
text=self.ask('时效优先')
self.assertIn('美森正班海派',text)
self.assertIn('3000KG = 30000元',text)
self.assertNotIn('普船海派',text)
def test_natural_cost_preference_selects_the_cheapest_after_recommendation(self):
self.ask('美国3000KG义乌美东海派报价')
self.ask('请帮我推荐一下')
text=self.ask('那我想省钱')
self.assertIn('3000KG = 18000元',text)
self.assertNotIn('美森正班海派',text)
def test_short_choice_confirmation_preserves_the_last_selected_quote(self):
self.ask('美国3000KG义乌美东海派报价')
self.ask('请帮我推荐一下')
self.ask('时效优先')
self.ask('就选这个')
self.assertIn('15天左右提取',self.ask('多久能提取'))
self.assertIn('3000KG = 30000元',self.ask('那一共多少钱'))
def test_quantity_changes_continue_after_a_short_confirmation(self):
self.ask('美国3000KG义乌美东美森正班海派报价')
self.ask('再加500KG')
self.ask('就按这个')
self.assertIn('2500KG = 25000元',self.ask('再减一吨'))
def test_no_book_for_a_named_us_service_does_not_use_legacy_regional_knowledge(self):
with patch('logistics_chat.quote_service.load_book',return_value=None), \
patch('logistics_chat.quote_data.load_book',return_value=None):
text=self.ask('美森正班海派多久能到')
self.assertIn('美国报价表',text)
self.assertNotIn('欧洲',text)
self.assertNotIn('15天',text)
def test_recommendation_without_shipment_details_asks_for_them(self):
text=self.ask('请帮我推荐一下,怎么走比较好')
self.assertIn('目的国家',text)
self.assertIn('货物重量',text)
self.assertNotIn('元/KG',text)
def test_explicit_service_after_comparison_clears_comparison_labels(self):
self.ask('美国3000KG义乌美东海派最快和最便宜报价')
text=self.ask('美森正班海派多久能到')
self.assertIn('15天左右提取',text)
self.assertNotIn('最快参考方案',text)
self.assertNotIn('最便宜参考方案',text)
self.assertNotIn('普船海派',text)
self.assertIn('3000KG = 30000元',self.ask('具体多少钱'))
def test_timing_and_liability_are_answered_without_losing_the_quote(self):
with self.app.app_context():
connection=db.connect()
connection.execute("UPDATE quote_rates SET eta=eta||? WHERE service=?",('\n无时效赔偿','美森正班海派'))
connection.commit()
connection.close()
self.ask('美国3000KG义乌美东海派报价')
self.ask('请帮我推荐一下')
text=self.ask('美森正班海派多久能到?如果到不了产生费用,你们公司会赔钱嘛?')
self.assertIn('15天左右提取',text)
self.assertIn('无时效赔偿',text)
self.assertIn('人工根据实际承运条款核实',text)
self.assertNotIn('请提供目的国家',text)
text=self.ask('美森正班海派多久能到')
self.assertIn('15天左右提取',text)
self.assertNotIn('欧洲',text)
self.assertNotIn('无时效赔偿',text)
self.assertIn('3000KG = 30000元',self.ask('具体多少钱'))
def test_mixed_claim_wording_with_punctuation_preserves_known_conditions(self):
self.ask('美国3000KG义乌美东海派报价')
for question in ('美森正班海派多久能到,延误会赔偿吗?',
'美森正班海派多久能到?产生的费用谁负责?'):
text=self.ask(question)
self.assertIn('15天左右提取',text)
self.assertIn('人工根据实际承运条款核实',text)
self.assertIn('3000KG = 30000元',self.ask('一共多少钱'))
def test_claim_processing_time_is_not_treated_as_shipping_time(self):
self.assertFalse(timing_with_claim('理赔需要多久'))
self.assertFalse(timing_with_claim('赔偿处理时效是几天'))
self.assertTrue(timing_with_claim('美森多久到,如果晚到会赔吗'))
def test_missing_liability_terms_are_not_invented(self):
text=self.ask('美森正班海派多久能到,延误会赔吗?')
self.assertIn('15天左右提取',text)
self.assertIn('实际承运条款核实',text)
self.assertNotIn('无时效赔偿',text)
def test_partial_liability_terms_are_not_applied_to_every_origin(self):
with self.app.app_context():
connection=db.connect()
connection.execute("UPDATE quote_rates SET eta=eta||? WHERE service=? AND origin=?",
('\n无时效赔偿','美森正班海派','义乌'))
connection.commit()
connection.close()
text=self.ask('美森正班海派多久到,延误会赔偿吗?')
self.assertIn('部分匹配方案注明',text)
self.assertNotIn('美森正班海派的渠道资料注明',text)
def test_fresh_named_us_service_uses_local_timing_without_a_country_prompt(self):
text=self.ask('美森正班海派多久能到')
self.assertIn('15天左右提取',text)
self.assertNotIn('欧洲',text)
self.assertNotIn('请提供目的国家',text)
self.assertNotIn('KG =',text)
def test_existing_bad_assistant_replies_do_not_prevent_context_recovery(self):
self.ask('一票3000KG发美国,义乌美东海派报价')
with self.app.app_context():
for question,reply in (
('请帮我推荐一下,怎么走比较好','请说明您想确认的渠道和费用。'),
('美森正班海派多久能到?如果晚到会赔偿吗?','请提供目的国家。'),
('美森正班海派多久能到','知识库只有欧洲海运信息。'),
):
db.save_message(self.cid,'user',question)
db.save_message(self.cid,'assistant',reply)
text=self.ask('那美森正班海派呢')
self.assertIn('15天左右提取',text)
self.assertIn('3000KG = 30000元',text)
self.assertNotIn('欧洲',text)
def test_other_country_context_is_not_silently_replaced_by_a_service_name(self):
with self.app.app_context():
db.save_message(self.cid,'user','新的一票100KG发德国海运报价')
db.save_message(self.cid,'assistant','请核对对应目的地报价。')
text=self.ask('美森正班海派多久能到')
self.assertIn('确认这票是否发往美国',text)
self.assertNotIn('15天',text)
self.assertNotIn('元/KG',text)
def test_known_us_warehouse_can_replace_another_destination_without_its_old_weight(self):
with self.app.app_context():
db.save_message(self.cid,'user','新的一票100KG发德国海运报价')
db.save_message(self.cid,'assistant','请核对对应目的地报价。')
text=self.ask('ZZZ1报价')
self.assertIn('美国',text)
self.assertNotIn('100KG',text)
self.assertNotIn('确认这票是否发往美国',text)
def test_cheapest_is_compared_by_price_instead_of_slowest_timing(self):
with self.app.app_context():
connection=db.connect()
connection.execute("UPDATE quote_rates SET price='3' WHERE service='合德海派'")
connection.commit()
connection.close()
self.ask('美国3000KG义乌美东海派报价')
text=self.ask('最快和最便宜相差多少钱')
cheap=text.split('最便宜参考方案')[1]
self.assertIn('合德海派',cheap)
self.assertIn('最快与最便宜方案可计算运费差额为21000元',text)
self.assertNotIn('最慢参考方案',text)
def test_english_fastest_and_cheapest_have_both_headings(self):
text=self.ask('3000kg to USA by sea, fastest and cheapest prices please')
self.assertIn('Fastest reference options',text)
self.assertIn('Cheapest reference options',text)