diff --git a/AGENTS.md b/AGENTS.md index a50292c..252d9a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +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 | | Reply presentation | `static/js/reply.js`: `ChatReply.create`; `chat.js`: `doSend`, network completion and display queue cancellation; `tests/frontend/reply.test.cjs` | +| Compound / quote followups | `logistics_chat/chat_questions.py`: `split_order_quote`, `answer_compound`; `quote_plans.py`: displayed option identities and chronological references; `quote_followup_fields.py`: missing shipment fields; `quote_comparison.py`: compact differences | | 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_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` | diff --git a/docs/architecture.md b/docs/architecture.md index 7dd71cf..ba841d3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,6 +83,8 @@ tests/ # 按业务模块组织的回归 报价支持同时比较最快和最便宜方案,推荐追问保留货物条件并按预算、时效分别列出选择。明确指定渠道会替换先前的比较选择。`quote_question_text` 从混合时效与赔付问句中提取报价条件,`timing_liability` 只引用当前匹配渠道已有的时效赔偿条款,其他费用由人工核实。独立理赔话题保留原有话题边界,其他目的地的上下文需先确认国家再使用美国渠道。 +`chat_questions.py` 将明确组合的订单统计与报价分开处理,再合并为一条回复,沿用订单部分的客户绑定、登录提示和列表状态。`quote_plans.py` 从当前报价话题内保存的实际回复识别方案编号,仅绑定当前价表中的渠道、交仓地、税费、计费单位和目的仓分区,并重新计算金额。多候选的“这个”及订单与报价之间的裸序号会先澄清。方案失效后要求重新选择。`quote_followup_fields.py` 只追问缺项,`quote_comparison.py` 先给同口径差额与参考时效,展开明细后恢复完整报价。 + 报价增减量按上一轮已确认的重量、体积和分箱明细计算。不同箱规的减箱追问需要更新后的逐箱明细。明确排除的渠道保存在报价条件中,快慢差额由 `quote_comparison.py` 按相同交仓地、区域、计费方式与税费口径计算。列表指代绑定原订单号,相邻票以实际已返回的订单卡片为起点,在当前已展示的序号范围内导航,到边界时引导翻页。 - 路由只解析请求并包装响应;服务组织业务;规则模块提供确定性计算。依赖方向为 `routes → services → rules / db / ai_client`,下层不导入路由或根入口。 diff --git a/logistics_chat/chat_questions.py b/logistics_chat/chat_questions.py new file mode 100644 index 0000000..8f7e7d4 --- /dev/null +++ b/logistics_chat/chat_questions.py @@ -0,0 +1,35 @@ +"""Separate explicit order statistics and freight questions without model calls.""" + +import re + +from .order_query import parse_query +from .quote_intent import US,QUOTE_WORDS + + +def split_order_quote(message): + parts=[part.strip() for part in re.split(r'[,,;;。??]+|同时|另外(?:问)?|顺便(?:问)?|以及|还有|并且',message) if part.strip()] + orders=[] + freight=[] + for part in parts: + if parse_query(part) is not None:orders.append(part) + else:freight.append(part) + quote=','.join(freight) + if not orders or not quote or not (US.search(quote) or QUOTE_WORDS.search(quote)): + return None + return ','.join(orders),quote + + +def answer_compound(message,conv_id): + parts=split_order_quote(message) + if parts is None:return None + from .order_data import answer_query + from .quote_service import answer_quote + from .quote_scope import answer_us_fallback + + orders,freight=parts + order_answer=answer_query(orders,conv_id) + if order_answer is None or 't' not in order_answer:return None + quote_answer=answer_quote(freight,conv_id) or answer_us_fallback(freight,conv_id) + if quote_answer is None: + quote_answer={'t':'报价部分暂未匹配到当前资料,请提供目的国家、目的仓码或邮编及重量,由人工核对。'} + return {**order_answer,'t':order_answer['t']+'\n\n'+quote_answer['t']} diff --git a/logistics_chat/chat_service.py b/logistics_chat/chat_service.py index a4d75f5..dfc8a6a 100644 --- a/logistics_chat/chat_service.py +++ b/logistics_chat/chat_service.py @@ -90,7 +90,13 @@ def create_chat_stream(msg, conv_id): if needs_customer_login(conv_id): return (sse(order_login_response()) + sse({'d':1}), {}) return (sse({'e':'该会话已删除,请新建对话后重试。'}), {}) - order_answer = answer_query(msg, conv_id) + from .chat_questions import answer_compound + order_answer = answer_compound(msg,conv_id) + if order_answer is None: + from .quote_service import answer_plan_question + order_answer=answer_plan_question(msg,conv_id) + if order_answer is None: + order_answer = answer_query(msg, conv_id) if order_answer is None: from .customer_faq import answer_capability order_answer = answer_capability(msg) diff --git a/logistics_chat/quote_comparison.py b/logistics_chat/quote_comparison.py index 0d9ae79..0da31ab 100644 --- a/logistics_chat/quote_comparison.py +++ b/logistics_chat/quote_comparison.py @@ -5,20 +5,21 @@ from decimal import Decimal,ROUND_HALF_UP from .quote_fees import charge_details -def speed_difference_lines(intent, book, rates, fees): - from .quote_engine import quantity, number, select_timed_rates,select_cheapest_rates +def speed_differences(intent, book, rates, fees): + from .quote_engine import quantity, select_timed_rates,select_cheapest_rates,reference_days groups = {} for rate in rates: key = (rate['origin'], rate['zone'], rate['kind'], rate['unit'], rate['tax']) groups.setdefault(key, []).append(rate) - lines = [] + result = [] for (origin, zone, kind, unit, tax), candidates in groups.items(): costs = [] 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): + selections=(select_timed_rates(candidates),comparison) + for selected in selections: values = [] for rate in selected: amount = quantity(intent, rate) @@ -30,9 +31,58 @@ def speed_difference_lines(intent, book, rates, fees): if not fast or not slow: continue low, high = min(fast) - max(slow), max(fast) - min(slow) - 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) - pair='最快与最便宜方案' if cheapest else '快慢方案' + timing=[] + for selected in selections: + bounds=[reference_days(rate) for rate in selected if reference_days(rate)] + timing.append((min(value[0] for value in bounds),max(value[1] for value in bounds)) if bounds else None) + result.append({'origin':origin,'zone':zone,'kind':kind,'unit':unit,'tax':tax, + 'low':low,'high':high,'timing':timing,'cheapest':cheapest}) + return result + + +def speed_difference_lines(intent, book, rates, fees): + from .quote_engine import number + + lines=[] + for row in speed_differences(intent,book,rates,fees): + label=' · '.join(value for value in (row['origin'],row['zone'],'包税' if row['tax']=='included' else '不包税', + '海派' if row['kind']=='parcel' else '海卡/海铁') if value) + difference=number(row['low']) if row['low']==row['high'] else number(row['low'])+'至'+number(row['high']) + pair='最快与最便宜方案' if row['cheapest'] else '快慢方案' lines.append(f'- {label},{pair}可计算运费差额为{difference}元。') return lines + + +def render_difference(intent,book,destinations,remote_fees): + from .quote_engine import match_rates,number,exact_number + from .quote_language import translate_quote + + groups={} + for destination,fees in zip(destinations,remote_fees): + part=destination.get('allocation') + billing=({**intent,'weight':part['weight'],'packages':part.get('packages'),'dims':None,'cbm':None,'boxes':part.get('boxes')} + if part else intent) + if part and intent.get('metal_chargeable_weight') is not None: + billing.update(metal_chargeable_weight=None,metal_scope='partial') + for row in speed_differences(billing,book,match_rates(billing,book,destination),fees): + key=(row['origin'],row['kind'],row['unit'],row['tax'],row['low'],row['high'],tuple(row['timing']), + row['cheapest'],destination['warehouse'] or destination['postal'],billing.get('weight')) + groups.setdefault(key,[]).append(row['zone']) + if not groups: + return ('Please provide weight, carton dimensions and routes with numeric reference transit times to compare costs.' + if intent.get('english') else '请提供重量、箱规及有明确数值时效的渠道,继续核算方案差额。') + lines=['按当前条件,同一交仓地、区域、计费单位和税费口径比较。'] + for (origin,kind,unit,tax,low,high,timing,cheapest,destination,weight),zones in groups.items(): + labels=[origin,destination or '/'.join(dict.fromkeys(zone for zone in zones if zone)), + '包税' if tax=='included' else '不包税','海派' if kind=='parcel' else '海卡/海铁'] + difference=number(low) if low==high else number(low)+'至'+number(high) + pair='最快与最便宜方案' if cheapest else '快慢方案' + line='- '+' · '.join(label for label in labels if label)+f',{pair}可计算运费差额为{difference}元。' + if weight:line+=f'按{exact_number(weight)}KG核算。' + if all(timing): + days=[number(bounds[0]) if bounds[0]==bounds[1] else number(bounds[0])+'–'+number(bounds[1]) for bounds in timing] + line+='参考时效分别约'+days[0]+'天、'+days[1]+'天。' + lines.append(line) + lines.append('回复“展开报价明细”可查看单价和各方案总价。') + text='\n'.join(lines) + return translate_quote(text) if intent.get('english') else text diff --git a/logistics_chat/quote_context.py b/logistics_chat/quote_context.py index 365868c..348bbb9 100644 --- a/logistics_chat/quote_context.py +++ b/logistics_chat/quote_context.py @@ -120,9 +120,19 @@ def apply_quantity_change(result,current,previous): def merge_intent(current,previous): + from .quote_plans import selected_intent + current=selected_intent(current,previous) result=deepcopy(current) excluded={'mode','selection','clear_selection','clear_choice','compare_services','choice_pending','excluded_services','quantity_change','tax_pending','volume_pending','quantity_question', - 'text','errors','dim_unit','quantity_note','quantity_uncertain','preserve_total','metal_weight_update','pending_weight','weight_unit'} + 'text','errors','dim_unit','quantity_note','quantity_uncertain','preserve_total','metal_weight_update','pending_weight','weight_unit', + 'plan_reference','plan_option','plan_handled'} + if (current.get('selection') or current.get('clear_selection') or current.get('clear_choice') or current.get('services')) and not current.get('selected_plan_number'): + excluded.update(('selected_plan_number','plan_unit','plan_destination')) + if current.get('plan_reference') is not None:excluded.add('plan_unit') + if current.get('plan_reference') is not None and current.get('warehouse') and previous.get('allocations'): + excluded.update(('cbm','dims')) + if any(current.get(key) for key in ('warehouse','postal','zone')) and not current.get('plan_destination'): + excluded.add('plan_destination') 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) @@ -148,6 +158,8 @@ def merge_intent(current,previous): if current.get('clear_selection'): excluded.update(('services','kinds','transport','air_only','air_requested')) if current.get('compare_services'):excluded.add('services') + if previous.get('selected_plan_number') and current.get('selection') and not current.get('services'): + excluded.add('services') blocked=[] if current.get('clear_selection') else previous.get('excluded_services') or [] blocked=list(dict.fromkeys(blocked+(current.get('excluded_services') or []))) if current.get('excluded_services'): diff --git a/logistics_chat/quote_engine.py b/logistics_chat/quote_engine.py index bc2631f..d527694 100644 --- a/logistics_chat/quote_engine.py +++ b/logistics_chat/quote_engine.py @@ -33,6 +33,9 @@ def variants(intent, book): warehouse = intent['warehouse'] found = [r for r in book['warehouses'] if r['code'] == warehouse] if warehouse else [] zoned = [r for r in found if r['zone']] + selected_destination=intent.get('plan_destination') + if selected_destination and selected_destination['warehouse']==warehouse: + zoned=[row for row in zoned if row['zone']==selected_destination['zone']] if zoned: unique = {} for row in zoned: @@ -89,6 +92,7 @@ def match_rates(intent, book, variant): selected = [] kinds = intent['kinds'] or (['truck','parcel'] if variant['warehouse'] else ['parcel']) for rate in book['rates']: + if intent.get('plan_unit') and rate['unit']!=intent['plan_unit']:continue if rate['kind'] not in kinds or (intent['origin'] and intent['origin'] != rate['origin']): continue if intent['services'] and not any(name in rate['service'] for name in intent['services']): @@ -129,16 +133,22 @@ def quantity_number(value,intent): return '约'+number(value,6) +def reference_days(rate): + match=re.search(r'(?0 and high>=low else None + + def select_timed_rates(rates,slowest=False,by_origin=False): """Compare numeric day ranges within one region, service kind and billing basis.""" ranked=[] best={} for rate in rates: - match=re.search(r'(?1 and not selected):return + intent['errors'].append('请明确选择'+ '、'.join('方案'+str(option['number']) for option in options)+',我再按该方案继续核算。') + intent['plan_handled']=True + return + intent['plan_handled']=True + if mixed and not re.search(r'方案|渠道|线路',message): + intent['errors'].append('您指的是第'+str(number)+'个订单,还是报价方案'+str(number)+'?') + return + option=next((option for option in options if option['number']==number),None) + if option is None: + intent['errors'].append('刚才展示了'+str(len(options))+'个方案,请选择其中的方案编号。') + elif option.get('destination_unavailable'): + intent['errors'].append('所选方案的目的仓分区已更新,请重新查看报价。') + intent['plan_unavailable']=intent['errors'][-1] + elif not option['rows']: + intent['errors'].append('方案'+str(number)+'暂无可用报价,请选择其他方案或补充货物条件。') + intent['plan_unavailable']=intent['errors'][-1] + else: + intent['plan_option']=option + if intent['mode']=='clarify_route':intent['mode']='quote' + + +def history_bindings(rows,book): + from .quote_intent import parse_intent + + options=[] + selected=False + mixed=False + bindings={} + previous_reference=None + pending=None + for message_id,role,content in rows: + if role=='assistant': + shown=displayed_options(content,book) + if shown and (not options or len(shown)>1 and previous_reference is None): + options=shown + mixed=any(line.startswith('- 入仓码:') for line in content.splitlines()) + continue + if role!='user':continue + previous_reference=plan_reference(content) + if previous_reference is not None: + probe=parse_intent(content,set()) + bind_reference(probe,content,options,selected,mixed) + if probe.get('plan_option'): + bindings[message_id]=probe['plan_option'] + selected=True + pending=None + elif probe.get('plan_unavailable'):pending=probe['plan_unavailable'] + else: + parsed=parse_intent(content,set()) + if parsed.get('selection') in ('fastest','slowest','cheapest') or parsed.get('services'): + selected=True + pending=None + elif parsed.get('selection') or parsed.get('clear_selection'): + selected=False + pending=None + return bindings,options,selected,mixed,pending + + +def selected_intent(current,previous): + option=current.get('plan_option') + if not option:return current + result=deepcopy(current) + rows=option['rows'] + result['services']=list(dict.fromkeys(row['service'] for row in rows)) + result['kinds']=list(dict.fromkeys(row['kind'] for row in rows)) + units=list(dict.fromkeys(row['unit'] for row in rows)) + result['plan_unit']=units[0] if len(units)==1 else None + origins=list(dict.fromkeys(row['origin'] for row in rows)) + result['origin']=origins[0] if len(origins)==1 else None + taxes=list(dict.fromkeys(row['tax'] for row in rows)) + result['tax']=taxes[0] if len(taxes)==1 else None + result['selection']=None + result['selected_plan_number']=option['number'] + for key in ('warehouse','postal','zone'): + if option.get(key):result[key]=option[key] + if option.get('warehouse'):result['warehouses']=[option['warehouse']] + if option.get('warehouse_zone'):result['plan_destination']={'warehouse':option['warehouse'],'zone':option['warehouse_zone']} + if option.get('zone'):result['zones']=[option['zone']] + if option.get('warehouse') and previous.get('allocations'): + part=next((part for part in previous['allocations'] if part['warehouse']==option['warehouse']),None) + if part:result.update(weight=part['weight'],boxes=part.get('boxes'),packages=part.get('packages')) + result.pop('plan_option',None) + return result diff --git a/logistics_chat/quote_service.py b/logistics_chat/quote_service.py index 0d8dcce..c80086c 100644 --- a/logistics_chat/quote_service.py +++ b/logistics_chat/quote_service.py @@ -11,21 +11,28 @@ 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,include_other=False): +def previous_quote_intents(conv_id,codes,resume=False,include_other=False,book=None,current=None): """Read the current quote back to its topic boundary, without a turn cutoff.""" earlier_intents=[] + entries=[] resume_updates=[] + resume_entries=[] if conv_id: connection=connect() try: - recent=connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC",(conv_id,)) - for (previous,) in recent: + recent=connection.execute("SELECT id,content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC",(conv_id,)) + for message_id,previous in recent: + from .chat_questions import split_order_quote + compound=split_order_quote(previous) + if compound:previous=compound[1] if is_acknowledgement(previous):continue earlier=parse_intent(previous,codes) if resumes_quote(previous): earlier_intents.append(earlier) + entries.append((message_id,earlier)) # Retain edits made after an explicit return while crossing other topics. resume_updates=earlier_intents[:] + resume_entries=entries[:] resume=True continue quote_text=quote_question_text(previous) @@ -40,12 +47,25 @@ def previous_quote_intents(conv_id,codes,resume=False,include_other=False): if boundary or not candidate: if not resume:break earlier_intents=resume_updates[:] + entries=resume_entries[:] continue if earlier['country']=='other': if include_other:earlier_intents.append(earlier) break earlier_intents.append(earlier) + entries.append((message_id,earlier)) if not is_followup(previous,earlier):break + if book and entries: + from .quote_plans import history_bindings,bind_reference + rows=connection.execute('SELECT id,role,content FROM messages WHERE conv_id=? AND id>=? ORDER BY id', + (conv_id,min(item[0] for item in entries))) + bindings,options,selected,mixed,pending=history_bindings(rows,book) + for message_id,earlier in entries: + if message_id in bindings:earlier['plan_option']=bindings[message_id] + if current is not None and options: + bind_reference(current,current['text'],options,selected,mixed) + if pending and current.get('plan_reference') is None and not any(current.get(key) for key in ('selection','services','clear_selection')): + current['errors'].append(pending) finally: connection.close() return earlier_intents @@ -58,7 +78,8 @@ def intent_with_context(message, conv_id, book): codes.update(value.upper() for rate in book['rates'] for value in rate['destinations']) intent = parse_intent(message,codes) 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) + plan_resume=intent.get('plan_reference') is not None and bool(re.search(r'方案|渠道|线路|\b(?:option|plan)',message,re.I)) + earlier_intents=(previous_quote_intents(conv_id,codes,resume=resumes_quote(message) or plan_resume,include_other=named_us_service,book=book,current=intent) if is_followup(message,intent) else []) if intent.get('quantity_change') and not earlier_intents: intent['errors'].append(intent['quantity_question']) @@ -76,7 +97,13 @@ def intent_with_context(message, conv_id, book): return intent -def answer_quote(message, conv_id): +def answer_plan_question(message,conv_id): + from .quote_plans import plan_reference,choice_confirmation + if not conv_id or (plan_reference(message) is None and not choice_confirmation(message)):return None + return answer_quote(message,conv_id,plan_only=True) + + +def answer_quote(message, conv_id,plan_only=False): if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', quote_question_text(message)): return None # Avoid a new read for ordinary chat; short followups need their local context. @@ -89,6 +116,7 @@ def answer_quote(message, conv_id): if not book: return None intent = intent_with_context(message, conv_id, book) + if plan_only and not intent.get('plan_handled'):return None 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在当前资料中是美国渠道。请确认这票是否发往美国,再继续核对时效和价格。'} diff --git a/static/js/chat.js b/static/js/chat.js index 1de006a..83ca137 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -1768,6 +1768,7 @@ async function doSend(retryText, showQuestion){ attachActions(aiBubble, raw, fullContent, !aiBubble.dataset.loginRequired); if(aiBubble.dataset.loginRequired) attachCustomerLogin(aiBubble, fullContent); renderFollowups(aiBubble); + scrollChatToEnd(); updateToBottom(); loadHistory(); // 首条消息会让标题变成问题内容,这里刷新一下 } diff --git a/tests/frontend/chat.test.cjs b/tests/frontend/chat.test.cjs index 23fe9f1..4765827 100644 --- a/tests/frontend/chat.test.cjs +++ b/tests/frontend/chat.test.cjs @@ -38,7 +38,10 @@ async function customerPage(pageOptions = {}){ getAttribute(name){ return this.attributes[name]; } addEventListener(name, callback){ this.listeners[name] = callback; } click(){ if(this.disabled) return; if(this.onclick) return this.onclick(); if(this.listeners.click) return this.listeners.click({target:this}); } - appendChild(child){ child.parent = this; this.children.push(child); } + appendChild(child){ + child.parent = this; this.children.push(child); + if(pageOptions.actionHeight && child.classList.contains('msg-actions')) element('chatDom').scrollHeight+=pageOptions.actionHeight; + } append(...children){ children.forEach(child => this.appendChild(child)); } contains(target){ return this === target || this.children.some(child=>child.contains(target)); } focus(options){ this.focusCalls.push(options); document.activeElement=this; if(this.listeners.focus) this.listeners.focus(); } @@ -317,6 +320,13 @@ test('a local completed reply keeps waiting feedback then progressively renders assert.equal(bubble.querySelector('.retry-btn'),null); }); +test('following the latest reply includes the final action toolbar in the scroll position', async () => { + const app=await customerPage({human:false,actionHeight:80}); + await app.context.window.fillInput('查询报价'); + await flush(); + assert.equal(app.element('chatDom').scrollTop,app.element('chatDom').scrollHeight); +}); + test('stopping during the waiting stage cancels pending presentation', async () => { const app=await customerPage({human:false,reducedMotion:false}); await app.context.window.fillInput('查询报价'); diff --git a/tests/test_conversation_composition.py b/tests/test_conversation_composition.py new file mode 100644 index 0000000..332e25a --- /dev/null +++ b/tests/test_conversation_composition.py @@ -0,0 +1,242 @@ +"""Mixed-domain answers and displayed quote references preserve scope and facts.""" + +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from logistics_chat import create_app,db,order_data +from logistics_chat.quote_data import import_snapshot +from test_quote_timing import timing_snapshot +from test_quotes import frames + + +class ConversationCompositionTests(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':'', + 'CUSTOMER_DEMO_TENANT_ID':'a','RATE_LIMIT_PER_MIN':1000}) + self.client=self.app.test_client() + self.client.post('/api/visitor/session',json={'key':'3'*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) + order_data.import_records(connection,[ + {'tenant_id':'a','order_no':'MIXORDER20260001','inbound_code':'MIXIN20260001','status':'PROCESSING', + 'exception_reason':'等待资料','source_updated_at':'2026-11-02 10:00'}, + {'tenant_id':'a','order_no':'MIXORDER20260002','inbound_code':'MIXIN20260002','status':'PROCESSING', + 'source_updated_at':'2026-11-01 10:00'}, + {'tenant_id':'b','order_no':'SECRETORDER20260001','exception_reason':'SECRET_REASON'}, + ]) + connection.commit() + connection.close() + + def login(self): + self.assertTrue(self.client.post('/api/customer/login',json={'username':'admin','password':'123456'}).json['ok']) + + 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.assertNotIn('SECRET',str(events)) + self.assertNotIn('plan_option',str(events)) + return events[0] + + def test_guest_gets_public_quote_and_login_prompt_without_order_reads(self): + with patch('logistics_chat.order_listing.read_orders') as read: + answer=self.ask('我有多少个订单,同时美国1000KG义乌美东海派最快多少钱') + read.assert_not_called() + self.assertTrue(answer['login_required']) + self.assertIn('1000KG = 10000元',answer['t']) + self.assertNotIn('MIXORDER',answer['t']) + + def test_customer_gets_both_answers_and_logout_protects_the_combined_history(self): + self.login() + answer=self.ask('我有多少个订单,同时美国1000KG义乌美东海派最快多少钱') + self.assertIn('共有 2 票订单',answer['t']) + self.assertIn('1000KG = 10000元',answer['t']) + self.assertTrue(self.client.get('/api/get_messages',query_string={'conv_id':self.cid}).json) + self.client.post('/api/customer/logout',json={}) + self.assertEqual(self.client.get('/api/get_messages',query_string={'conv_id':self.cid}).json,[]) + + def test_reverse_domain_order_and_followup_quantity_keep_both_results(self): + self.login() + answer=self.ask('美国1000KG义乌美东海派最快多少钱,顺便我有多少异常订单') + self.assertIn('1 票明确标记异常',answer['t']) + self.assertIn('1000KG = 10000元',answer['t']) + self.assertIn('1100KG = 11000元',self.ask('再加100KG')['t']) + + def test_mixed_reply_requires_domain_for_bare_ordinal_and_supports_explicit_ones(self): + self.login() + self.ask('我有多少个订单,同时美国1000KG义乌美东海派最快和最便宜多少钱') + ambiguous=self.ask('第二个呢')['t'] + self.assertIn('订单',ambiguous) + self.assertIn('报价方案2',ambiguous) + self.assertEqual(self.ask('第二个订单')['card']['no'],'MIXORDER20260002') + quote=self.ask('第二个方案')['t'] + self.assertIn('1000KG = 6000元',quote) + self.assertNotIn('美森正班海派',quote) + + def test_second_displayed_plan_remains_selected_through_acknowledgement_and_edits(self): + self.ask('美国1000KG义乌美东海派最快和最便宜多少钱') + selected=self.ask('第二个方案呢')['t'] + self.assertIn('当前按方案2继续核算',selected) + self.assertIn('1000KG = 6000元',selected) + self.ask('就选这个') + self.assertIn('25-30天左右提取',self.ask('多久能提取')['t']) + self.assertIn('1100KG = 6600元',self.ask('再加100KG')['t']) + first=self.ask('第一个方案')['t'] + self.assertIn('1100KG = 11000元',first) + self.assertNotIn('普船海派',first) + + def test_prompted_plan_number_is_accepted_and_ambiguous_confirmation_is_clarified(self): + self.ask('美国1000KG义乌美东海派最快和最便宜多少钱') + self.assertIn('方案1、方案2',self.ask('就这个')['t']) + selected=self.ask('我选方案2')['t'] + self.assertIn('1000KG = 6000元',selected) + self.assertIn('普船海派',selected) + + def test_priority_change_after_a_numbered_selection_can_switch_carriers(self): + self.ask('美国1000KG义乌美东海派最快和最便宜多少钱') + self.ask('方案1') + text=self.ask('那我想省钱')['t'] + self.assertIn('普船海派',text) + self.assertNotIn('美森正班海派',text) + self.assertIn('1000KG = 6000元',text) + + def test_english_displayed_options_remain_english(self): + self.ask('1000kg to USA by sea, fastest and cheapest prices please') + selected=self.ask('Option 2')['t'] + self.assertIn('Regular sea parcel',selected) + self.assertIn('Continuing with option 2',selected) + self.assertNotRegex(selected,r'[\u4e00-\u9fff]') + + def test_english_compact_difference_contains_no_chinese_text(self): + from logistics_chat.quote_data import load_book + from logistics_chat.quote_engine import parse_intent,variants,render_rates + with self.app.app_context(): + book=load_book() + intent=parse_intent('1000kg to USA by sea fastest and slowest prices',set()) + intent['mode']='difference' + destinations=variants(intent,book) + text=render_rates(intent,book,destinations,[None]*len(destinations)) + self.assertNotRegex(text,r'[\u4e00-\u9fff]') + self.assertIn('4000 CNY',text) + + def test_out_of_range_plan_does_not_choose_a_route(self): + self.ask('美国1000KG义乌美东海派最快和最便宜多少钱') + text=self.ask('第9个方案')['t'] + self.assertIn('展示了2个方案',text) + self.assertNotIn('KG =',text) + + def test_displayed_choice_preserves_carrier_identity_after_prices_change(self): + self.ask('美国1000KG义乌美东海派最快和最便宜多少钱') + with self.app.app_context(): + connection=db.connect() + connection.execute("UPDATE quote_rates SET price='3' WHERE service='美森正班海派'") + connection.commit() + connection.close() + selected=self.ask('第二个方案')['t'] + self.assertIn('普船海派',selected) + self.assertIn('1000KG = 6000元',selected) + self.assertNotIn('美森正班海派',selected) + + def test_removed_displayed_carrier_requires_a_new_selection_through_followups(self): + self.ask('美国1000KG义乌美东海派最快和最便宜多少钱') + with self.app.app_context(): + connection=db.connect() + connection.execute("DELETE FROM quote_rates WHERE service='普船海派'") + connection.commit() + connection.close() + self.assertIn('暂无可用报价',self.ask('方案2')['t']) + self.assertIn('暂无可用报价',self.ask('那具体多少钱')['t']) + text=self.ask('全部渠道报价')['t'] + self.assertIn('美森正班海派',text) + self.assertNotIn('暂无可用报价',text) + + def test_new_quote_root_replaces_the_old_plan_catalog(self): + self.ask('美国1000KG义乌美东海派最快和最便宜多少钱') + self.ask('新的一批200KG美国义乌美西海派报价') + selected=self.ask('第二个方案')['t'] + self.assertIn('合德海派',selected) + self.assertIn('200KG = 1200元',selected) + self.assertNotIn('1000KG',selected) + + def test_legacy_unnumbered_rows_keep_their_positions_after_a_carrier_is_removed(self): + from logistics_chat.quote_plans import displayed_options + from logistics_chat.quote_data import load_book + content='美东:\n- 旧渠道 · 义乌 · 包税:5元/KG\n- 普船海派 · 义乌 · 包税:6元/KG' + with self.app.app_context(): + options=displayed_options(content,load_book()) + self.assertEqual([option['number'] for option in options],[1,2]) + self.assertEqual(options[0]['rows'],[]) + self.assertEqual(options[1]['rows'][0]['service'],'普船海派') + + def test_removed_warehouse_does_not_reuse_the_previous_destination(self): + from logistics_chat.quote_plans import displayed_options + from logistics_chat.quote_data import load_book + content='美东:\n- 普船海派 · 义乌 · 包税:6元/KG\nABC1 · 东1区:\n- 普船海派 · 义乌 · 包税:6元/KG' + with self.app.app_context(): + options=displayed_options(content,load_book()) + self.assertEqual(options[1]['warehouse'],'ABC1') + self.assertTrue(options[1]['destination_unavailable']) + + def test_different_visitor_cannot_use_private_plan_bindings(self): + self.login() + self.ask('我有多少订单,同时美国1000KG义乌美东海派最快和最便宜多少钱') + other=self.app.test_client() + other.post('/api/visitor/session',json={'key':'4'*64}) + with patch('logistics_chat.quote_plans.history_bindings') as read: + response=frames(other.post('/stream_chat',json={'msg':'方案2','conv_id':self.cid})) + read.assert_not_called() + self.assertTrue(any(event.get('e') for event in response)) + + def test_known_box_count_and_weights_are_not_requested_again(self): + text=self.ask('美国10箱每箱20KG义乌美西普船海派报价')['t'] + question=text.splitlines()[-1] + self.assertIn('每箱长宽高',question) + self.assertIn('目的仓码或邮编',question) + self.assertNotIn('箱数',question) + self.assertNotIn('重量',question) + text=self.ask('每箱50x40x30cm')['t'] + question=text.splitlines()[-1] + self.assertNotIn('长宽高',question) + self.assertNotIn('重量',question) + + def test_all_known_shipment_fields_produce_no_repeated_followup(self): + text=self.ask('美国10箱每箱20KG每箱50x40x30cm,ZZZ1,义乌普船海派报价')['t'] + self.assertNotIn('请补充',text) + + def test_selected_warehouse_variant_preserves_its_zone_and_assigned_weight(self): + snapshot=timing_snapshot() + snapshot['source_sha256']='2'*64 + snapshot['warehouses'].append({**snapshot['warehouses'][0],'code':'ALT1','zone':'西1区','postal':'90001'}) + with self.app.app_context(): + connection=db.connect() + import_snapshot(connection,snapshot,activate=True) + connection.commit() + connection.close() + self.ask('美国ZZZ1 100KG,ALT1 200KG,义乌海派报价,总体积10CBM') + # ZZZ1 has two zones, with three parcel options in each zone. + selected=self.ask('方案4')['t'] + self.assertIn('ZZZ1 · 东2区',selected) + self.assertNotIn('东1区',selected) + self.assertNotIn('ALT1',selected) + self.assertIn('100KG = 1100元',selected) + self.assertNotIn('300KG',selected) + + def test_difference_is_short_and_details_expand_without_losing_conditions(self): + self.ask('美国1000KG海派最快和最慢报价') + text=self.ask('快的贵多少')['t'] + self.assertLess(len(text),350) + self.assertIn('差额为4000元',text) + self.assertIn('15天、25–30天',text) + self.assertNotIn('1000KG =',text) + details=self.ask('展开报价明细')['t'] + self.assertIn('1000KG = 10000元',details) + self.assertIn('1000KG = 6000元',details) diff --git a/tests/test_customer_gap_fixes.py b/tests/test_customer_gap_fixes.py index 69dddc2..5d561aa 100644 --- a/tests/test_customer_gap_fixes.py +++ b/tests/test_customer_gap_fixes.py @@ -63,7 +63,7 @@ class CustomerGapTests(unittest.TestCase): text=self.ask(q,cid) self.assertNotIn('21KG',text);self.assertNotIn('250',text);self.assertNotIn('7元/KG',text) text=self.ask('我想发点货到美国,怎么收费?') - self.assertIn('元/KG',text);self.assertIn('多少公斤',text) + self.assertIn('元/KG',text);self.assertIn('总重或各箱重量',text) def test_final_total_respects_tax_labels_and_keeps_unconfirmed_fees_separate(self): cid=self.conversation();self.ask('美国美西2箱每箱15KG,40x30x20cm,义乌普船海派',cid) diff --git a/tests/test_quote_changes.py b/tests/test_quote_changes.py index 100189a..1acabca 100644 --- a/tests/test_quote_changes.py +++ b/tests/test_quote_changes.py @@ -133,10 +133,12 @@ class QuoteChangeTests(unittest.TestCase): text=self.ask('那比慢的贵多少') self.assertIn('运费差额为4000元',text) - def test_ambiguous_plan_reference_keeps_context_for_a_named_selection(self): + def test_numbered_plan_reference_selects_the_displayed_slow_option(self): self.ask('美国1000KG海运最快和最慢报价') self.assertIn('1000KG',self.ask('这个多少钱')) - self.assertIn('请指定方案名称',self.ask('第二个方案呢')) + selected=self.ask('第二个方案呢') + self.assertIn('普船海派',selected) + self.assertNotIn('美森正班海派',selected) self.assertIn('1000KG = 4000元',self.ask('便宜的那个')) self.assertIn('1000KG = 4000元',self.ask('一共呢')) @@ -184,9 +186,12 @@ class QuoteChangeTests(unittest.TestCase): c.execute("UPDATE quote_rates SET eta='船开后16天左右' WHERE service='美森正班海派' AND origin='华南'") c.commit();c.close() text=self.ask('美国1000KG美东海派最快最慢差价') - self.assertIn('美森正班海派 · 义乌',text) - self.assertIn('美森正班海派 · 华南',text) + self.assertIn('义乌',text) + self.assertIn('华南',text) self.assertEqual(text.count('运费差额为4000元'),2) + detail=self.ask('展开报价明细') + self.assertIn('美森正班海派 · 义乌',detail) + self.assertIn('美森正班海派 · 华南',detail) def test_volume_increments_keep_the_confirmed_total_and_units(self): self.ask('美国2CBM义乌美东普船海派报价') diff --git a/tests/test_quote_conversation.py b/tests/test_quote_conversation.py index 426b9ee..bbe7218 100644 --- a/tests/test_quote_conversation.py +++ b/tests/test_quote_conversation.py @@ -221,10 +221,10 @@ class QuoteConversationTests(unittest.TestCase): connection.close() self.ask('美国3000KG义乌美东海派报价') text=self.ask('最快和最便宜相差多少钱') - cheap=text.split('最便宜参考方案')[1] - self.assertIn('合德海派',cheap) self.assertIn('最快与最便宜方案可计算运费差额为21000元',text) self.assertNotIn('最慢参考方案',text) + detail=self.ask('展开报价明细') + self.assertIn('合德海派',detail.split('最便宜参考方案')[1]) def test_english_fastest_and_cheapest_have_both_headings(self): text=self.ask('3000kg to USA by sea, fastest and cheapest prices please') diff --git a/tests/test_quotes.py b/tests/test_quotes.py index 63ee6cb..19d3bdf 100644 --- a/tests/test_quotes.py +++ b/tests/test_quotes.py @@ -96,7 +96,7 @@ class QuoteTests(unittest.TestCase): self.assertIn('美国 30KG',text) fresh = self.ask('ZZZ1') self.assertNotIn('美国 30KG',fresh) - self.assertIn('多少公斤',fresh) + self.assertIn('总重或各箱重量',fresh) def test_new_topics_do_not_reuse_old_quote_or_cross_order_privacy(self): from logistics_chat.quote_service import answer_quote