Compare commits

...
17 changed files with 422 additions and 36 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.
+1 -1
View File
@@ -13,7 +13,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
| Order lookup UI | `static/js/order_lookup.js`: `OrderLookup.create`; `chat.js`: `showOrderLookup`, `fillTrackCard`; `support.js`: `copyText` |
| Chat drafts | `static/js/drafts.js`: `ChatDrafts.create`; `chat.js`: `flushDraft`, `restoreDraft`, startup recovery |
| Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` |
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `quote_intent.py`: `parse_intent`; `quote_service.py`: `answer_quote`; `quote_engine.py`: `quantity`, `render_rates`; `quote_answers.py`, `quote_fees.py`: local business terms and fees |
| 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` |
+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 配置。
+2
View File
@@ -80,6 +80,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',
+4 -1
View File
@@ -7,11 +7,14 @@ from flask import current_app, jsonify, request
def rate_limit():
path = request.path
if request.endpoint == 'pages.index' and request.method in ('GET', 'HEAD'):
return None
if path in ('/healthz', '/api/visitor/human/poll') or path.startswith('/static') or path.startswith('/api/human/'):
return None
ip = request.remote_addr or 'unknown'
now = time.time()
hits = current_app.extensions['rate_hits'][ip]
bucket = (ip, 'chat') if request.endpoint == 'chat.stream_chat' else ip
hits = current_app.extensions['rate_hits'][bucket]
while hits and now - hits[0] > 60:
hits.pop(0)
if len(hits) >= current_app.config['RATE_LIMIT_PER_MIN']:
+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
+38 -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,16 @@ 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.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 +115,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 +227,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 +240,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):
+42 -1
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)
@@ -359,12 +368,44 @@ class ApiTests(unittest.TestCase):
def test_health_check_remains_available_when_business_requests_are_rate_limited(self):
self.app.config['RATE_LIMIT_PER_MIN'] = 1
self.assertEqual(self.client.get('/').status_code, 200)
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
self.assertEqual(self.client.get('/api/get_history').status_code, 429)
response = self.client.get('/healthz')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json, {'status': 'ok'})
def test_homepage_does_not_consume_or_depend_on_business_rate_budget(self):
self.app.config['RATE_LIMIT_PER_MIN'] = 1
for method in ('get', 'head'):
response = getattr(self.client, method)('/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.mimetype, 'text/html')
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
self.assertEqual(self.client.get('/api/get_history').status_code, 429)
for method in ('get', 'head'):
response = getattr(self.client, method)('/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.mimetype, 'text/html')
def test_chat_and_supporting_requests_have_separate_limited_budgets(self):
self.app.config['RATE_LIMIT_PER_MIN'] = 1
response = self.client.post('/stream_chat', json=[])
self.assertEqual(response.status_code, 200)
self.assertEqual(response.mimetype, 'text/event-stream')
self.assertEqual(self.client.post('/stream_chat', json=[]).status_code, 429)
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
self.assertEqual(self.client.get('/api/get_history').status_code, 429)
self.assertEqual(self.client.get('/stream_chat').status_code, 429)
def test_supporting_request_limit_does_not_block_chat(self):
self.app.config['RATE_LIMIT_PER_MIN'] = 1
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
self.assertEqual(self.client.get('/api/get_history').status_code, 429)
response = self.client.post('/stream_chat', json=[])
self.assertEqual(response.status_code, 200)
self.assertEqual(response.mimetype, 'text/event-stream')
self.assertEqual(self.client.post('/stream_chat', json=[]).status_code, 429)
def test_feedback_stats_and_csv(self):
cid = self.conversation()
with self.app.app_context():
+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()
+211
View File
@@ -0,0 +1,211 @@
"""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_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)