Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2409bb5c36 | ||
|
|
a98ae0b8c6 | ||
|
|
31a92f25f4 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 配置。
|
||||
|
||||
@@ -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`,下层不导入路由或根入口。
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 '),
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user