"""Compose knowledge, freight and model output into the existing SSE protocol.""" import json import re from . import ai_client from .ai_client import compress_answer, gen_followups, has_chinese from .config import get_setting from .db import connect, last_user_question, load_context, save_message from .knowledge import search_knowledge from .pricing import (parse_shipping_intent, detect_dim_unit, freight_reply, calc_warehouse_card, detect_surcharge, surcharge_reply, check_island, is_shipping_destination_reply) from .tracking import CARD_PREFIX from .order_data import answer_query, extract_reference def sse(payload): """统一的事件格式:data: {"t":正文} / {"e":错误} / {"card":卡片} / {"d":1结束} JSON 编码可以避免正文里的换行把 SSE 帧撑断。""" return 'data: ' + json.dumps(payload, ensure_ascii=False) + '\n\n' QUOTE_FOLLOWUP_PREFIX = r'^(?:那(?:么)?|换成|改成|改为|如果|还是|再算|同样)' def is_shipping_followup(message, intent=None): """Recognize bounded quote edits without carrying facts across new topics.""" if re.search(r'新(?:的)?一(?:票|批)|另(?:一|外一?)(?:票|批)|新(?:的)?(?:货|订单)|换个话题|理赔|赔偿|破损|丢失|签收|退件|退款|投诉', message): return False intent = intent if intent is not None else parse_shipping_intent(message) text = message.strip().strip(',,。.!!??、:: ') prefix = re.match(QUOTE_FOLLOWUP_PREFIX, text) if prefix and any(intent.get(key) is not None for key in ('weight', 'dims', 'channel', 'region')): return True if intent['region'] is not None and is_shipping_destination_reply(text): return True short = re.sub(QUOTE_FOLLOWUP_PREFIX, '', text, count=1).strip() short = re.sub(r'^(?:请问|请|再问一下)', '', short).strip() if re.fullmatch(r'(?:海运|海派|空运|空派|铁路|班列|卡航|卡车|sea(?:\s+freight)?|air(?:\s+freight)?|rail|truck)' r'(?:报价|价格|费用|运费|时效)?(?:呢|怎么样|如何|多少钱|多久|几天)?', short, re.I): return True if re.fullmatch(r'(?:价格|费用|运费|时效)(?:呢|是多少|多少|怎么样|如何|多久|几天)?|多少钱', short): return True if intent['weight'] is not None and re.fullmatch(r'\d+(?:\.\d+)?\s*(?:kg|公斤|千克|吨|lbs?|tons?|kilograms?)(?:呢)?', short, re.I): return True return bool(intent['dims'] and re.fullmatch(r'\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*(?:cm|mm|m|厘米|毫米|米)?(?:呢)?', short, re.I)) def shipping_intent_with_context(message, conv_id): intent = parse_shipping_intent(message) intent['dim_unit'] = detect_dim_unit(message) if conv_id and is_shipping_followup(message, intent): connection = connect() try: recent = connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 6", (conv_id,)).fetchall() finally: connection.close() for (previous,) in recent: if extract_reference(previous): break earlier = parse_shipping_intent(previous) if not any(earlier.get(key) is not None for key in ('weight', 'dims', 'channel', 'region')): if is_shipping_followup(previous, earlier): continue break for key in ('weight', 'channel', 'region'): if intent.get(key) is None: intent[key] = earlier.get(key) if intent['dims'] is None and earlier['dims']: intent['dims'] = earlier['dims'] intent['dim_unit'] = detect_dim_unit(previous) if not is_shipping_followup(previous, earlier): break if intent['channel']: base = intent['channel'].removesuffix('_uk') intent['channel'] = base + '_uk' if intent['region'] == 'uk' else base return intent def create_chat_stream(msg, conv_id): if not msg: return (sse({'e': '问题不能为空'}), {}) if conv_id: from .visitor_service import owns_conversation if not owns_conversation(conv_id): from .customer_service import needs_customer_login, order_login_response if needs_customer_login(conv_id): return (sse(order_login_response()) + sse({'d':1}), {}) return (sse({'e':'该会话已删除,请新建对话后重试。'}), {}) order_answer = answer_query(msg, conv_id) if order_answer is None and re.fullmatch(r'\[图片\]/uploads/\S+', msg): order_answer = {'t':'收到您的图片,请描述一下您的问题。'} ship = shipping_intent_with_context(msg, conv_id) if order_answer is None else None if ship and ship['region'] is None and (ship['weight'] is not None or ship['dims'] or re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) or (ship['channel'] and (is_shipping_followup(msg, ship) or re.search(r'报价|价格|费用|运费|多少钱|\b(?:quote|cost|price)\b', msg, re.I)))): order_answer = {'t':'请提供目的国家或地区、目的仓或邮编,以便匹配对应费率;当前无法确定报价,可联系人工客服确认。'} if ship and ship['region'] == 'unsupported' and (ship['weight'] is not None or ship['dims'] or ship['channel'] or is_shipping_destination_reply(msg) or re.search(r'报价|价格|运费|费用|多少钱', msg)): order_answer = {'t':'当前报价表尚未包含该目的地的费率,请提供目的仓或邮编、货物品名、件数和尺寸,由人工客服确认报价。'} if ship and ship['region'] == 'uk' and re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) and not ship['weight'] and not ship['dims']: order_answer = {'t':'当前报价表未提供该目的地的按方费率,请提供目的仓或邮编、实际重量和尺寸,由人工客服确认报价。'} if order_answer is not None: customer_data = order_answer.pop('_customer_data',False) or 'card' in order_answer def track_stream(): if customer_data: from .customer_service import bind_order_conversation, current_customer_id if current_customer_id() and not bind_order_conversation(conv_id): yield sse({'e':'该会话暂时无法访问,请新建对话后重试。','d':1}) return save_message(conv_id, 'user', msg) content = (CARD_PREFIX + json.dumps(order_answer['card'], ensure_ascii=False) if 'card' in order_answer else order_answer['t']) save_message(conv_id, 'assistant', content) yield sse(order_answer) yield sse({'d': 1}) return (track_stream(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) if not get_setting('DEEPSEEK_API_KEY'): return (sse({'e': '服务未配置 API Key,请在 .env 或环境变量中设置 DEEPSEEK_API_KEY 后重启服务'}), {}) know_text = search_knowledge(msg) freight_text = None if ship['weight'] or ship['dims']: freight_text = freight_reply(ship) if not ship['channel']: freight_text += '\n\n(客户没有指定渠道,上面已同时给出海运和空运,请在回答末尾询问更看重时效还是成本)' extra_parts = [] cbm_m = re.search('(\\d+(?:\\.\\d+)?)\\s*(?:cbm|方|立方)', msg, re.I) if cbm_m and ship['region'] != 'uk': cbm = float(cbm_m.group(1)) ch = '海运' if re.search('卡航|卡车', msg): ch = '卡航' elif re.search('铁路|班列', msg): ch = '铁路' elif re.search('普卡', msg): ch = '普卡' want = None for kw, ctry in (('德国', 'DE'), ('法国', 'FR'), ('波兰', 'PL'), ('捷克', 'CZ'), ('西班牙', 'ES'), ('马德里', 'ES'), ('中国', '')): if kw in msg: want = ctry break card = calc_warehouse_card(cbm, ch, country=want) if card: store = card['warehouse'].splitlines()[0] extra_parts.append(f"【海外仓卡派 · 按方计费】\n 仓点:{store}\n 计费:{card['note']}\n 渠道:{card['channel']},适用档位 {card['tier']}\n 运费:{card['formula']}\n 说明:单箱实重超 35KG 加收 30 元/箱(封顶 3600 元);不接受仓储,特殊情况 85 元/箱/周") sur = detect_surcharge(msg) if sur: extra_parts.append(surcharge_reply(sur[0], sur[1], ship.get('weight'))) pc_m = re.search('(? limit: short = compress_answer(full_text, limit) if len(short) > limit: short = compress_answer(short, limit - 60) if short != full_text: full_text = short yield sse({'replace': short}) save_message(conv_id, 'assistant', full_text) answer_saved = True if is_first_turn: try: fu = gen_followups(msg, full_text) if fu: yield sse({'suggest': fu}) except Exception: pass yield sse({'d': 1}) except GeneratorExit: interrupted = True raise except Exception: interrupted = True yield sse({'e': '传输中断,请点击重试'}) finally: text = ''.join(parts) try: if interrupted and text and not answer_saved: save_message(conv_id, 'assistant', text + '\n\n(回答中断,以上内容未生成完整)') finally: resp.close() return (generate(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})