diff --git a/AGENTS.md b/AGENTS.md index b300ea8..a6cd684 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/` | Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `quote_intent.py`: `parse_intent`; `quote_service.py`: `answer_quote`; `quote_engine.py`: `quantity`, `render_rates`; `quote_answers.py`, `quote_fees.py`: local business terms and fees | | US quote data | `logistics_chat/quote_workbook.py`: local detail export; `quote_data.py`: versioned SQLite imports; `quote_engine.py`: matching/calculation; `quote_service.py`: conversational reply; `scripts/import_quote_data.py` | | Common customer questions | `logistics_chat/shipping_faq.py`: pre-shipment material; `quote_allocations.py`: per-warehouse weights; `quote_context.py`: chronological edits; `order_explanations.py`: business status, latest actual nodes and operation boundaries | -| Reply boundaries / order summaries | `logistics_chat/customer_faq.py`: supported actions; `quote_scope.py`: US-only fallback; `quote_edits.py`: rejected conditions; `quote_material.py`: partial metal chargeable weight; `order_query.py`: bounded filters; `order_listing.py`: scoped five-row summaries and conversation pages; `order_tracking.py`: `current_stage` | +| Reply boundaries / order summaries | `logistics_chat/customer_faq.py`: supported actions and acknowledgements; `quote_scope.py`: US-only fallback; `quote_edits.py`: rejected conditions; `quote_material.py`: partial metal chargeable weight; `order_query.py`: bounded filters; `order_listing.py`: scoped five-row summaries and conversation pages; `order_followups.py`: list ordinals and subset questions; `order_tracking.py`: `current_stage` | | Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` | | Order test data | `logistics_chat/order_data.py`: `lookup_order`, `count_orders`, `answer_query`, `import_records`; `scripts/import_order_test_data.py` | | Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` | diff --git a/docs/architecture.md b/docs/architecture.md index 41d0fb3..bcb3cfc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,6 +78,8 @@ tests/ # 按业务模块组织的回归 ## 模块边界与依赖方向 +连续追问的列表指代和子集条件由 `order_followups.py` 处理。“第一票”定位服务端保存的已展示回复,取数时重新校验当前客户范围。“其中”沿用已有筛选条件,普通状态追问可以切换条件。礼貌确认语保留当前话题,显式返回报价由 `quote_service.py` 恢复最近报价条件。`quote_intent.py` 分别保存渠道选择和本轮询问主题,计算总价、查询时效与税费时沿用已选方案。 + - 路由只解析请求并包装响应;服务组织业务;规则模块提供确定性计算。依赖方向为 `routes → services → rules / db / ai_client`,下层不导入路由或根入口。 - `db.py` 统一连接、schema 和历史持久化。人工与管理业务的 SQL 放在所属服务,HTTP 路由不执行 SQL。请求及流式请求结束时关闭连接,未提交事务回滚。 - `init_db` 包括客服账号、订单与留言表,首次启动只建空表;不内置账号。重复初始化保留已有数据,业务测试从真实初始化结果插入夹具,不另建缺失表。 diff --git a/logistics_chat/customer_faq.py b/logistics_chat/customer_faq.py index 04fe189..a98207f 100644 --- a/logistics_chat/customer_faq.py +++ b/logistics_chat/customer_faq.py @@ -3,7 +3,15 @@ import re +def is_acknowledgement(message): + text=re.sub(r'[\s,,。.!!??]','',message).lower() + return bool(re.fullmatch(r'(?:好的?|嗯+|明白了?|了解了?|知道了?|收到|谢谢(?:你|您)?|多谢|没问题|' + r'ok(?:ay)?|thanks?|thankyou|gotit|understood)+',text)) + + def answer_capability(message): + if is_acknowledgement(message): + return {'t':'好的,有需要随时告诉我。' if re.search(r'[\u4e00-\u9fff]',message) else 'You are welcome. Let me know if you need anything else.'} if re.search(r'照片|图片',message) and re.search(r'报价|价格|判断|识别|尺寸',message): return {'t':'可以通过输入框旁的“+”上传照片供人工查看。当前聊天不会自动识别图片,请同时用文字提供品名、重量、箱数、尺寸和目的仓码或邮编,再核算报价。'} if re.search(r'(?:手机号|手机号码|电话号码).*(?:查|订单|运单)|(?:查|查询).*手机号',message): diff --git a/logistics_chat/order_data.py b/logistics_chat/order_data.py index f5abab3..94c2b9f 100644 --- a/logistics_chat/order_data.py +++ b/logistics_chat/order_data.py @@ -340,6 +340,11 @@ def answer_query(message, conv_id): if overview is not None:return overview explanation=general_explanation(message) if explanation:return {'t':explanation} + from .order_followups import answer_followup + followup=answer_followup(message,tenants,conv_id) + if followup is not None: + if '_order_reference' not in followup:return followup + references=[followup['_order_reference']] count_intent = parse_count_query(message) if not references else None if count_intent is not None: return answer_count_query(count_intent,tenants,conv_id) @@ -354,27 +359,29 @@ def answer_query(message, conv_id): reference = references[0] if references else None if not reference and conv_id and is_order_followup(message): from .db import connect + from .customer_faq import is_acknowledgement connection = connect() try: - recent = connection.execute('SELECT role,content FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 12', (conv_id,)).fetchall() + recent = connection.execute('SELECT role,content FROM messages WHERE conv_id=? ORDER BY id DESC', (conv_id,)) + for role, content in recent: + if role == 'user': + if is_acknowledgement(content):continue + earlier_references = extract_reference(content) + if earlier_references: + reference = earlier_references[0] if len(earlier_references) == 1 else None + break + if not is_order_followup(content): + break + if role == 'assistant' and content.startswith('__CARD__'): + try: + previous = json.loads(content[8:]) + if previous.get('kind') == 'order': + reference = previous['no'] + break + except (ValueError, KeyError, TypeError): + pass finally: connection.close() - for role, content in recent: - if role == 'user': - earlier_references = extract_reference(content) - if earlier_references: - reference = earlier_references[0] if len(earlier_references) == 1 else None - break - if not is_order_followup(content): - break - if role == 'assistant' and content.startswith('__CARD__'): - try: - previous = json.loads(content[8:]) - if previous.get('kind') == 'order': - reference = previous['no'] - break - except (ValueError, KeyError, TypeError): - pass if not reference: if mutation: return order_login_response() if not tenants else {'t':'当前支持查询订单与物流轨迹;改址、取消等操作请联系人工客服办理,尚未修改订单。请提供入仓码。'} diff --git a/logistics_chat/order_followups.py b/logistics_chat/order_followups.py new file mode 100644 index 0000000..3b2ceee --- /dev/null +++ b/logistics_chat/order_followups.py @@ -0,0 +1,100 @@ +"""Resolve bounded list references and subset questions from scoped saved replies.""" + +import re +import sqlite3 + +from .db import connect +from .order_query import COUNT,NOUN,CONDITION,parse_query + + +ORDINALS={word:index for index,word in enumerate( + '一 二 三 四 五 六 七 八 九 十 十一 十二 十三 十四 十五 十六 十七 十八 十九 二十'.split(),1)} +RELATIVE=r'^(?:这其中|其中|这些(?:订单|运单)?(?:中|里)?|这几(?:票|条)(?:中|里)?|里面|当中)' + + +def is_relative_question(message): + return (bool(re.match(RELATIVE,message)) + and bool(re.search(COUNT+'|'+NOUN+'|'+CONDITION+r'|轨迹|节点|阶段|到哪|在哪',message,re.I)) + and not re.search(r'报价|海运|海派|海卡|空运|运费|费用|价格|KG|公斤|千克|CBM|立方',message,re.I)) + + +def ordinal_request(message): + from .order_data import is_order_followup + text=re.sub(r'[\s,,。.!!??]','',message) + match=re.fullmatch(r'(?:请|帮我|看看|看一下|查询)?第(\d{1,5}|[一二三四五六七八九十]{1,3})' + r'(?:个|票|条|笔)(?:订单|运单)?(?:的)?(.*)',text) + if not match:return None + detail=match[2] + if detail not in ('','呢','详情') and not is_order_followup(detail):return None + number=int(match[1]) if match[1].isdigit() else ORDINALS.get(match[1],0) + return number + + +def shown_references(connection,conv_id,state): + if 'shown' in state: + return [(item['inbound_code'] or '未登记',item['order_no']) for item in state['shown']] + row=connection.execute("SELECT content FROM messages WHERE conv_id=? AND id=? AND role='assistant'", + (conv_id,state['message_id'])).fetchone() + # Older states stored only the reply ID. Accept its fixed, unambiguous row format. + lines=[line for line in (row[0] if row else '').splitlines() if line.startswith('- 入仓码:')] + result=[] + for line in lines: + match=re.match(r'^- 入仓码:([^\r\n;]+);订单号:([^\r\n;]+);(?:业务状态|异常原因):',line) + if not match or any(line.count(label)>1 for label in (';订单号:',';业务状态:',';异常原因:')): + return [] + result.append(match.groups()) + return result + + +def answer_followup(message,tenants,conv_id): + from .customer_service import order_login_response + from .order_listing import load_state,answer_summary + + text=message.strip(' ,,。.!!??') + ordinal=ordinal_request(text) + relative=is_relative_question(text) + reason=bool(re.fullmatch(r'(?:那|这些|这几票|它们|上述|上面)?(?:订单|运单)?' + r'(?:为什么异常|为什么有异常|异常的?原因(?:是什么|有哪些|呢)?|异常是怎么回事)',text)) + query_text=re.sub(RELATIVE,'',text) + query_text=re.sub(r'^(?:那么|那)','',query_text) + intent=parse_query(query_text+'订单有多少') if not (re.search(NOUN,query_text,re.I) or re.search(COUNT,query_text,re.I)) else parse_query(query_text) + if relative and intent is None and re.search(COUNT,query_text,re.I):intent=parse_query(query_text+'订单') + short_count=(not re.search(NOUN,text,re.I) and not re.search(COUNT,text,re.I) + and intent is not None and not intent['unsupported'] + and any(intent.get(key) for key in ('status','stage','exception','grouped'))) + if ordinal is None and not relative and not reason and not short_count:return None + if not tenants:return order_login_response() + try: + previous=load_state(conv_id) + if ordinal is not None: + if not previous:return {'t':'请先查看需要查询的订单列表,或直接提供入仓码。'} + connection=connect() + try: + shown=shown_references(connection,conv_id,previous) + finally:connection.close() + if not shown:return {'t':'当前列表没有可查询的订单,请重新指定筛选条件,或直接提供入仓码。'} + if not 1<=ordinal<=len(shown): + return {'t':f'刚才的列表展示了{len(shown)}票,请选择其中一票,或回复“查看全部”展开列表后再查询。'} + inbound,number=shown[ordinal-1] + return {'_order_reference':number if inbound=='未登记' else inbound} + if short_count and not relative and (not previous or previous.get('selected')):return None + if reason: + if not previous or previous.get('selected'):return None + intent={**previous['query'],'exception':True,'unsupported':False} + elif relative: + if not previous:return {'t':'请先查询要筛选的订单范围,再继续问“其中”的数量。'} + if previous.get('selected'): + return {'t':'您想统计刚才列表中的订单,还是查询当前这一票?请说明订单范围或提供入仓码。'} + if intent is None or intent['unsupported'] or 'summaries' in intent: + return {'t':'请明确要在当前订单范围内筛选的一个条件,例如“其中异常的有多少票”。'} + for key in ('status','stage'): + if previous['query'][key] and intent.get(key) and previous['query'][key]!=intent[key]: + return {'t':'当前范围与这次条件对应不同状态或节点,请确认要切换筛选条件,还是继续查看原范围。'} + intent={**intent,**{key:intent.get(key) or value for key,value in previous['query'].items()}} + elif intent.get('grouped'): + if not previous or previous.get('selected'):return None + intent={**intent,**previous['query']} + if intent is None or intent['unsupported']:return None + return answer_summary(intent,tenants,conv_id,reasons=reason) + except sqlite3.Error: + return {'t':'订单信息暂时无法读取,请稍后重试。'} diff --git a/logistics_chat/order_listing.py b/logistics_chat/order_listing.py index aaba595..4dadfa4 100644 --- a/logistics_chat/order_listing.py +++ b/logistics_chat/order_listing.py @@ -30,6 +30,9 @@ def valid_filters(query): def load_state(conv_id): from .customer_service import current_customer_id,current_scope_key from .visitor_service import visitor_id + from .customer_faq import is_acknowledgement + from .order_followups import ordinal_request,shown_references,is_relative_question + from .order_data import is_order_followup if not conv_id:return None connection=connect() try: @@ -40,8 +43,23 @@ def load_state(conv_id): if not isinstance(state,dict) or not valid_filters(state.get('query')):return None if type(state.get('page')) is not int or not 1<=state['page']<=10000:return None if state.get('size') not in (5,20) or type(state.get('message_id')) is not int:return None + if 'shown' in state: + shown=state['shown'] + if not isinstance(shown,list) or len(shown)>20:return None + if any(not isinstance(item,dict) or set(item)!={'inbound_code','order_no'} + or not isinstance(item['order_no'],str) or not item['order_no'] + or item['inbound_code'] is not None and not isinstance(item['inbound_code'],str) for item in shown): + return None + state['selected']=False for (text,) in connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' AND id>? ORDER BY id", (conv_id,state['message_id'])): + if is_acknowledgement(text):continue + if is_relative_question(text):continue + ordinal=ordinal_request(text) + if ordinal is not None: + state['selected']=1<=ordinal<=len(shown_references(connection,conv_id,state)) + continue + if state['selected'] and is_order_followup(text):continue if page_command(text) is None and parse_query(text) is None and text.strip() not in ('查看全部','查看全部订单'): return None return state @@ -166,10 +184,12 @@ def read_orders(tenants,query,page=1,size=5,comparisons=()): finally:connection.close() -def row_text(row): +def row_text(row,reason_only=False): from .order_data import STATUS_LABELS def clean(value):return re.sub(r'[\r\n\t]+',' ',str(value or '未登记')) - text=f'- 入仓码:{clean(row["inbound_code"])};订单号:{clean(row["order_no"])};业务状态:{STATUS_LABELS.get(row["status"],row["status"])};目的仓:{clean(row["warehouse_code"])};当前节点:{STAGE_LABELS[row["stage"]]}' + text=f'- 入仓码:{clean(row["inbound_code"])};订单号:{clean(row["order_no"])}' + if not reason_only: + text+=f';业务状态:{STATUS_LABELS.get(row["status"],row["status"])};目的仓:{clean(row["warehouse_code"])};当前节点:{STAGE_LABELS[row["stage"]]}' if row['exception_reason']:text+=';异常原因:'+clean(row['exception_reason']) return text @@ -196,7 +216,7 @@ def stage_distribution(groups): f'- {label}:{groups[code]}票' for code,label in STAGE_LABELS.items() if groups[code]] -def answer_summary(intent,tenants,conv_id=None): +def answer_summary(intent,tenants,conv_id=None,reasons=False): from .customer_service import order_login_response if not tenants:return order_login_response() if intent['unsupported']:return unsupported() @@ -220,12 +240,13 @@ def answer_summary(intent,tenants,conv_id=None): question=re.sub(r'\s+',' ',part['question'])[:120] lines.append(f'“{question}”的筛选条件尚未明确,请确认具体含义,可按业务状态、异常或当前实际节点查询。') if data['rows']: - lines.append(f'最近更新的{len(data["rows"])}票:') - lines.extend(row_text(row) for row in data['rows']) + lines.append('已记录的异常原因(最多5票)。' if reasons else f'最近更新的{len(data["rows"])}票:') + lines.extend(row_text(row,reason_only=reasons) for row in data['rows']) lines.append('回复“查看全部”展开列表,或发送入仓码查询详情。') state={'query':query,'page':1,'size':5} previous=load_state(conv_id) if previous and previous['query']==query:state={key:previous[key] for key in ('query','page','size')} + state['shown']=[{'inbound_code':row['inbound_code'],'order_no':row['order_no']} for row in data['rows']] return {'t':'\n'.join(lines),'_customer_data':True,'_order_list_state':state} except sqlite3.Error:return {'t':'订单信息暂时无法读取,请稍后重试。'} @@ -251,7 +272,8 @@ def answer_list(message,tenants,conv_id=None): if intent is None or intent['unsupported']:return unsupported(True) query=filters(intent);page=1;size=20 data=read_orders(tenants,query,page,size);page=data['page'];total=data['total'] - state={'query':query,'page':page,'size':size} + state={'query':query,'page':page,'size':size, + 'shown':[{'inbound_code':row['inbound_code'],'order_no':row['order_no']} for row in data['rows']]} if not total:return {'t':'当前账号下没有符合条件的订单。','_customer_data':True,'_order_list_state':state} label=STATUS_LABELS[query['status']] if query['status'] else '全部' first=f'先列出{size}票;' if page==1 and total>size else '' diff --git a/logistics_chat/order_query.py b/logistics_chat/order_query.py index 5d6b414..372760c 100644 --- a/logistics_chat/order_query.py +++ b/logistics_chat/order_query.py @@ -29,6 +29,7 @@ STATUSES = (('头程订单已完成','COMPLETED'),('已完成','COMPLETED'),(' ('processing','PROCESSING')) NOUN = r'订单|运单|入仓码|入仓号|\borders?\b|\bshipments?\b' COUNT = r'多少|几(?:个|票|条|张|笔)|总数|数量|总量|统计|数一下|(?:订单|运单)数(?!量)|\bhow\s+many\b|\bcount\b|\bnumber\b|\btotal\b' +CONDITION = r'异常|\b(?:exception|abnormal)\b|'+'|'.join(pattern for _,pattern in NODE_PHRASES)+'|'+'|'.join(re.escape(phrase) for phrase,_ in STATUSES) def parse_query(message,listing=False): @@ -36,8 +37,7 @@ def parse_query(message,listing=False): message=re.sub(r'^(?:或者|那么|那)[,,\s]*','',message) message=re.sub(r'^(?:当前|目前|现在)[,,\s]+','',message) has_noun=bool(re.search(NOUN,message,re.I)) - condition=r'异常|\b(?:exception|abnormal)\b|'+'|'.join(pattern for _,pattern in NODE_PHRASES)+'|'+'|'.join(re.escape(phrase) for phrase,_ in STATUSES) - if not has_noun and (listing or not re.search(COUNT,message,re.I) or not re.search(condition,message,re.I)): + if not has_noun and (listing or not re.search(COUNT,message,re.I) or not re.search(CONDITION,message,re.I)): return None if not listing and not (re.search(COUNT,message,re.I) or re.search(r'分布|(?:都|分别).*到哪',message)):return None if re.search(r'公斤|千克|\bkg\b|重量|毛重|体积|件数|箱数|运费|费用|价格|多少钱',message,re.I):return None diff --git a/logistics_chat/quote_answers.py b/logistics_chat/quote_answers.py index 4593fed..2de78f5 100644 --- a/logistics_chat/quote_answers.py +++ b/logistics_chat/quote_answers.py @@ -94,7 +94,13 @@ def answer_business(intent,book): return '请提供品名、材质、是否带电及所选渠道,先核对承运条件,再计算对应费用。' if mode=='fees': lines=[] - if re.search(r'含税|含派送|全包|包不包',text): + if re.search(r'含税|包税|税费',text): + from .quote_engine import variants,match_rates + taxes={rate['tax'] for destination in variants(intent,book) for rate in match_rates(intent,book,destination)} + if taxes=={'included'}:lines.append('当前匹配方案标注包税,按该方案的税费口径核算。') + elif taxes=={'excluded'}:lines.append('当前匹配方案标注不包税,税费需单独核对。') + else:lines.append('当前存在不同税费口径,请指定包税或不包税方案继续核对。') + if re.search(r'含派送|全包|包不包',text): lines.append('报价行标注“包税”或“不包税”的方案按各自税费方式计算;海派的派送还需核对目的仓/邮编,住宅、商业地址及偏远等附加费不能直接当作已全部包含。') metal=material_fee(book) if metal is not None and re.search(r'铁制|钢制|铝制|铁铝|不锈钢',text): diff --git a/logistics_chat/quote_context.py b/logistics_chat/quote_context.py index d17a948..eb392ff 100644 --- a/logistics_chat/quote_context.py +++ b/logistics_chat/quote_context.py @@ -2,7 +2,8 @@ from copy import deepcopy from decimal import Decimal -import re + +from .quote_intent import kg,MISSING_WEIGHT_UNIT QUANTITY_QUESTION='箱数已更改,请确认新的总重或各箱重量,原分箱明细不能直接套用。' @@ -10,10 +11,20 @@ QUANTITY_QUESTION='箱数已更改,请确认新的总重或各箱重量,原 def merge_intent(current,previous): result=deepcopy(current) - if (current['mode']=='quote' and previous['mode'] in ('cheapest','fastest','slowest','speed_compare') - and not re.search(r'全部|所有|各(?:个)?渠道',current['text'])): - result['mode']=previous['mode'] - excluded={'mode','text','errors','dim_unit','quantity_note','quantity_uncertain','preserve_total','metal_weight_update'} + excluded={'mode','selection','clear_selection','text','errors','dim_unit','quantity_note','quantity_uncertain','preserve_total','metal_weight_update','pending_weight','weight_unit'} + unit=current.get('weight_unit') + pending=previous.get('pending_weight') + weight_change=current['weight'] is not None or bool(unit and pending is not None) + if unit: + if pending is None:result['errors'].append('请同时提供重量数值和单位,例如800KG。') + else:result['weight']=str(kg(pending,unit)) + 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'): + result['selection']=previous.get('selection') + if current.get('clear_selection'): + excluded.update(('services','kinds','transport','air_only','air_requested')) if current.get('metal_weight_update') and previous.get('metal_chargeable_weight') is None: result['metal_chargeable_weight']=None result['errors'].append('请确认铁铝部分的计费重,不能把毛重直接当作计费重。') @@ -30,12 +41,12 @@ def merge_intent(current,previous): if current.get('allocations'):excluded.update(('packages','boxes','dims','cbm')) if current['transport'] or current['kinds'] or current['services']:excluded.update(('air_only','air_requested')) if current['air_only'] or current['tax']=='excluded':excluded.add('kinds') - if current['weight'] is not None: + if weight_change: if not current.get('packages'):excluded.add('packages') if not current.get('allocations'):excluded.add('allocations') if current['dims']:excluded.add('cbm') if current['cbm'] is not None:excluded.add('dims') - if any(current.get(k) is not None for k in ('weight','dims','cbm','boxes','packages','allocations','transport','kinds','services','tax')) and current.get('metal_chargeable_weight') is None: + if (weight_change or any(current.get(k) is not None for k in ('dims','cbm','boxes','packages','allocations','transport','kinds','services','tax'))) and current.get('metal_chargeable_weight') is None: excluded.add('metal_chargeable_weight') if current.get('material')=='ordinary':excluded.update(('metal_scope','metal_chargeable_weight')) if current.get('metal_scope')=='all' and current.get('metal_chargeable_weight') is None: @@ -49,7 +60,7 @@ def merge_intent(current,previous): for piece in pieces:piece.update(dims=current['dims'],dim_unit=current['dim_unit']) elif current['cbm'] is not None: for piece in pieces:piece.update(dims=None,dim_unit='cm') - if previous.get('quantity_uncertain') and current['weight'] is None and not current.get('preserve_total'): + if previous.get('quantity_uncertain') and not weight_change and not current.get('preserve_total'): result['quantity_uncertain']=True result['errors'].append(QUANTITY_QUESTION) if pieces and result['boxes']!=sum(p['count'] for p in pieces): diff --git a/logistics_chat/quote_engine.py b/logistics_chat/quote_engine.py index a7f4fd3..4260028 100644 --- a/logistics_chat/quote_engine.py +++ b/logistics_chat/quote_engine.py @@ -150,6 +150,7 @@ def render_rates(intent, book, destinations, remote_fees): from .quote_language import translate_quote lines = [] + rate_selection=intent.get('selection') or intent.get('mode') if intent.get('mode')=='time': lines.append('美国线路参考时效:') elif intent['weight']: @@ -172,7 +173,7 @@ def render_rates(intent, book, destinations, remote_fees): if part and intent.get('metal_chargeable_weight') is not None: billing.update(metal_chargeable_weight=None,metal_scope='partial') rates = match_rates(billing, book, destination) - if intent.get('mode')=='cheapest' and rates: + if rate_selection=='cheapest' and rates: eligible=[r for r in rates if quantity(billing,r) is not None] best={} for rate in eligible: @@ -187,11 +188,11 @@ def render_rates(intent, book, destinations, remote_fees): if destination['label'] != '美国': lines.append(destination['label']+':') selections=[('',rates)] - if intent.get('mode') in ('fastest','slowest','speed_compare'): + if rate_selection in ('fastest','slowest','speed_compare'): selections=[] - if intent['mode'] in ('fastest','speed_compare'): + if rate_selection in ('fastest','speed_compare'): selections.append(('最快参考方案',select_timed_rates(rates))) - if intent['mode'] in ('slowest','speed_compare'): + if rate_selection in ('slowest','speed_compare'): selections.append(('最慢参考方案',select_timed_rates(rates,slowest=True))) lines.append('按相同区域、运输方式、计费单位及税费方式比较参考时效。最快按天数下限、最慢按天数上限选择。') grouped = {} @@ -206,10 +207,11 @@ def render_rates(intent, book, destinations, remote_fees): if selection and selection!=last_selection: lines.append(selection) last_selection=selection + eta = re.sub(r'\s*无时效赔偿', '', eta_source).strip() + if rows[0]['kind']=='parcel' and eta and '提取' not in eta:eta+='提取' + elif rows[0]['kind']=='truck' and eta and '船开' not in eta:eta='开船后参考'+eta if intent.get('mode')=='time': - eta=eta_source or '当前资料未列明时效' - if rows[0]['kind']=='parcel' and '提取' not in eta:eta+='提取' - lines.append(f'- {service} · {origin}:{eta}。提取不等于送达或签收。') + lines.append(f'- {service} · {origin},{eta or "当前资料未列明时效"}。') continue parts = [] for rate in rows: @@ -228,9 +230,6 @@ def render_rates(intent, book, destinations, remote_fees): elif intent.get('cbm') and rate['unit']=='cbm': lines.append('计费体积四舍五入后不足0.01CBM,最低计费体积需人工确认,不能按零费用出价。') parts.append(rate_text) - eta = re.sub(r'\s*无时效赔偿', '', eta_source).strip() - if rows[0]['kind']=='parcel' and eta and '提取' not in eta:eta+='提取' - elif rows[0]['kind']=='truck' and eta and '船开' not in eta:eta='开船后参考'+eta lines.append(f"- {service} · {origin} · {'包税' if tax=='included' else '不包税'}:"+';'.join(parts) + (f'({eta})' if eta else '')) if fees: @@ -241,7 +240,7 @@ def render_rates(intent, book, destinations, remote_fees): if not any(intent.get(key) for key in ('warehouse','postal')): lines.append('发我目的仓码或邮编,可继续匹配到仓线路。') if intent.get('mode')=='time': - pass + lines.append('以上时效按开船后到提取计算,送达时间需结合末端派送进度核对。') elif not intent['weight'] and not intent['cbm'] and not intent['dims']: lines.append('货物多少公斤、几箱?') elif not intent['boxes'] or not intent['dims']: diff --git a/logistics_chat/quote_intent.py b/logistics_chat/quote_intent.py index 1ba90eb..dd44481 100644 --- a/logistics_chat/quote_intent.py +++ b/logistics_chat/quote_intent.py @@ -9,10 +9,16 @@ US = re.compile(r'美国|美西|美中|美东|洛杉矶|纽约|芝加哥|休斯 r'(?