import logging import re import uuid from collections.abc import AsyncGenerator from datetime import datetime, timedelta, timezone from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.database import ( Conversation, ConversationStatus, ConversationSource, Message, MessageRole, CustomerProfile, Evaluation, IntentLevel, LeadPushLog, ) from app.services import email_service, lead_push_service from app.services.ai_service import AIService from app.services.boundary_filter import ( NO_CONTACT_ALREADY, NO_CONTACT_APP, NO_CONTACT_WEB, asks_contact, deflection_for, no_contact_value, pivot_for, split_sentences, violates_boundary, ) from app.services.contact_pipeline import ( canon, latest_stated_contacts, merge_contacts, normalize_contacts, resolve_by_recency, scan_user_messages, split_validate, ) logger = logging.getLogger(__name__) # 职能边界触发词(Task 13.2, 评审#1/#4 修订): 命中即注入红线提醒。精修说明: # - 裸 "how long"/"多久" 不收(规格参数类问题常用),英文要求 take/arrive/deliver/ship 邻接, # 中文只收 到货/多久能到/交货 等明确交付语境 # - 【评审#1】施压话术本身也触发: 磨价轮("just roughly"/"说个大概")没有价格词, # 却是最易破防的时刻——压力词单列一组 # - 【评审#4】v1 补齐漏词: expensive/fee/贵/价钱/总价/落地/划算/免费(短语式)等; # cost/fee 用 \b 边界,costa rica 不再误命中("costa"含cost但无词边界) # - 原则: 误触发低害(多一条提醒),漏触发高危(红线失守),词表偏宽;上线后漏词案例回流补词 BOUNDARY_TRIGGER_RE = re.compile( r"price|pricing|how\s+much|\bcost\b|discount|cheaper|expensive|\bfee\b|" r"quote|quotation|shipping\s+(fee|cost|time)|freight|tariff|customs\s+fee|" r"best\s+offer|any\s+offer|free\s+shipping|" r"sample|certificat|\bmoq\b|minimum\s+order|min\s+order|" r"container\s+loading|fit\s+in|per\s+container|warranty|质保|保修|" r"book(ing)?\s|reserv|arrange|" r"delivery|lead\s+time|arriv|" r"how\s+long\s+(does|will|would|to).*(take|arriv|deliver|ship)|" r"roughly|ballpark|just\s+tell|come\s+on|at\s+least\s+(a|some|give)|" r"budget|预算|价格|价钱|多少钱|报价|优惠|折扣|便宜|贵|划算|" r"总价|落地|运费|关税|免费|" r"到货|多久能到|交货|时效|发货|样品|证书|装柜|起订|" r"预约|下单|定金|安排|" r"大概|估个|估一|随便说|说个|求你|求了", re.IGNORECASE, ) # 预算路径路由(评审#9): 命中 BOUNDARY 触发词且含预算词 → 走预算 playbook BUDGET_TRIGGER_RE = re.compile(r"budget|预算", re.IGNORECASE) # 索要我方联系方式触发(15.2, doctrine v2): 客户问"你们怎么联系/你的微信多少" # = 最高意向信号。提醒层按 source 分支: web 指按钮, App 无按钮走反向留资 # - 排除第一人称将来时: "I'll call you later" 是日常告别语,不是索要联系方式。 # 必须用定宽 negative lookbehind——lookahead 在动词位置求值,永远看不到 # 前面的 let me / i'll,不生效。四个 lookbehind 全部定宽合法: i'll(5)、 # i’ll(5, 弯撇 U+2019 手机常见)、i will(7)、let me(7);IGNORECASE 兼容大写 CONTACT_ASK_TRIGGER_RE = re.compile( r"(your|our)\s+(whatsapp|number|phone|email|contact)|" r"(? str: if _CJK_RE.search(msg or ""): return LANG_NOTE_ZH if _ES_FEATURE_RE.search(msg or ""): return LANG_NOTE_ES return "" # 方案B(13.6): 迂回话术轮换序号——连续破防时不至于复读同一句 _boundary_seq = 0 def _boundary_rotate() -> int: global _boundary_seq _boundary_seq += 1 return _boundary_seq class ConversationManager: def __init__(self, db: AsyncSession, ai_service: AIService | None = None): self.db = db self.ai = ai_service or AIService() async def create_conversation( self, source: ConversationSource = ConversationSource.standalone, client_info: dict | None = None, page_token: str | None = None, context: dict | None = None, ) -> Conversation: session_id = uuid.uuid4().hex conv = Conversation( session_id=session_id, source=source, client_info=client_info, page_token=page_token, ) self.db.add(conv) await self.db.commit() await self.db.refresh(conv) # Conditional whitelist seed of app context into raw_info (red line): # only when `context` is provided, and only product_name/product_id # mapped to app_* keys. WS path passes no context -> raw_info stays {}. raw_info: dict = {} if context: if context.get("product_name"): raw_info["app_product_name"] = context["product_name"] if context.get("product_id"): raw_info["app_product_id"] = context["product_id"] profile = CustomerProfile(conversation_id=conv.id, raw_info=raw_info) self.db.add(profile) await self.db.commit() return conv async def get_by_session(self, session_id: str) -> Conversation | None: result = await self.db.execute( select(Conversation).where(Conversation.session_id == session_id) ) return result.scalar_one_or_none() async def get_by_page_token(self, page_token: str) -> Conversation | None: result = await self.db.execute( select(Conversation).where( Conversation.page_token == page_token, Conversation.status == ConversationStatus.active, ) ) return result.scalar_one_or_none() async def stream_process_message( self, session_id: str, content: str ) -> AsyncGenerator[dict, None]: """Stream AI reply token by token, then do extract/evaluate.""" conv = await self.get_by_session(session_id) if not conv: yield {"type": "error", "message": "session not found"} return if conv.status != ConversationStatus.active: yield {"type": "error", "message": "conversation ended"} return # Save user message user_msg = Message( conversation_id=conv.id, role=MessageRole.user, content=content ) self.db.add(user_msg) await self.db.commit() history = await self._load_history(conv.id) # Build context reminder (merged into system prompt) profile = await self._get_profile(conv.id) # P0 同轮竞态消除: 当前消息即时正则扫描——仅并入本轮内存视图 # (提醒/话术路由),不落库;持久化仍由轮末 extractor+scanner 负责, # 邮件触发时序不变。 current_scan = scan_user_messages([{"role": "user", "content": content}]) effective_raw = dict(profile.raw_info or {}) if current_scan: effective_raw["联系方式"] = merge_contacts( current_scan, effective_raw.get("联系方式")) context_reminder = self._build_context_reminder( effective_raw, content, history, source=conv.source.value) if CONTACT_ASK_TRIGGER_RE.search(content or ""): logger.info("Contact-ask reminder fired (source=%s)", conv.source.value) # Enrich short user messages with context so AI doesn't lose track history_for_ai = list(history) enriched = self._enrich_short_message(content, profile.raw_info or {}, history) if history_for_ai and history_for_ai[-1]["role"] == "user" and enriched != content: history_for_ai[-1] = {**history_for_ai[-1], "content": enriched} logger.info("Enriched short message: %r -> %r", content[:50], enriched[:100]) # 方案B'(13.7): 句子粒度门控——干净句子逐句流出(恢复打字机), # 违规句永不流出:已流出内容全部干净,违规时接过渡话术收尾并掐断生成。 # F6(D6联动): 客户已留联系方式时,收尾话术不得再索要——路由 contact 版 has_contact = bool(split_validate( normalize_contacts(effective_raw.get("联系方式")))[0]) yield {"type": "stream_start"} emitted = "" pending = "" violated = False dropped_ask = False # Task 5b: 本轮是否有索要句被再索要墙丢弃 dirty = "" # 15.3: 双墙裁决——价格墙检查在前: 同句双违规时价格路径话术优先 # (职能边界话术更具体,且自带留资引导);仅纯号码/邮箱违规时 # violation=="no_contact",用零号码墙替换句 violation = "" async for token in self.ai.stream_chat(history_for_ai, context=context_reminder): pending += token complete, pending = split_sentences(pending) for sentence in complete: if violates_boundary(sentence): violated = True dirty = sentence violation = "boundary" break if no_contact_value(sentence, history_for_ai): violated = True dirty = sentence violation = "no_contact" break # Task 5b 再索要墙: 已留资时索要句静默丢弃(丢句不掐断, # 其余句子照常流出——软于两墙的掐断+替换) if has_contact and asks_contact(sentence): logger.warning("Re-ask wall dropped sentence (conv=%s): %r", conv.id, sentence[:60]) dropped_ask = True continue for i in range(0, len(sentence), 40): yield {"type": "stream_chunk", "content": sentence[i:i + 40]} emitted += sentence if violated: break # 提前掐断生成,省 token if not violated and pending: if violates_boundary(pending): violated = True dirty = pending violation = "boundary" elif no_contact_value(pending, history_for_ai): violated = True dirty = pending violation = "no_contact" elif has_contact and asks_contact(pending): # Task 5b 再索要墙: 尾巴是索要句 → 静默丢弃(不流出不掐断) logger.warning("Re-ask wall dropped tail (conv=%s): %r", conv.id, pending[:60]) dropped_ask = True else: for i in range(0, len(pending), 40): yield {"type": "stream_chunk", "content": pending[i:i + 40]} emitted += pending if violated: if violation == "no_contact": # 零号码墙替换句: 已留资→确认话术(不再索要);未留资按 source 分支 # (App 无按钮 → 反向留资; web/widget/standalone → 指顶部按钮) logger.warning( "No-contact wall replaced sentence (conv=%s): %r", conv.id, dirty[:60]) closing = (NO_CONTACT_ALREADY if has_contact else (NO_CONTACT_APP if conv.source.value == "app" else NO_CONTACT_WEB)) else: logger.warning( "Boundary filter intercepted sentence (conv=%s): %r | tail=%r", conv.id, dirty[:80], (pending or "")[:40]) closing = (pivot_for(content, has_contact) if emitted else deflection_for(content, _boundary_rotate(), has_contact=has_contact)) for i in range(0, len(closing), 40): yield {"type": "stream_chunk", "content": closing[i:i + 40]} emitted += closing # Task 5b 评审修正: 整条回复只有索要句(全被丢)→ 兜底流出"经理主动 # 联系"句,绝不空回复。与上方墙收尾互斥——violated 路径已自带收尾, # 此处仅在无任何实义内容流出时接管;已流出的纯空白前缀一并作废, # 落库恒为完整兜底句(客户看到的比一个空格更糟的是: 什么都没有)。 if not violated and dropped_ask and not emitted.strip(): logger.warning("Re-ask wall dropped entire reply (conv=%s), " "falling back to closing", conv.id) for i in range(0, len(NO_CONTACT_ALREADY), 40): yield {"type": "stream_chunk", "content": NO_CONTACT_ALREADY[i:i + 40]} emitted = NO_CONTACT_ALREADY yield {"type": "stream_end"} ai_reply = emitted # 落库与流出统一为已流出文本(下方持久化) # Save assistant message (skip if empty) if ai_reply.strip(): assistant_msg = Message( conversation_id=conv.id, role=MessageRole.assistant, content=ai_reply ) self.db.add(assistant_msg) await self.db.commit() # Round 2: Extract info (background) full_history = history + [{"role": "assistant", "content": ai_reply}] profile = await self._get_profile(conv.id) # Task 12b: 轮前联系方式快照(extractor 深合并 + 覆盖 + 同号同步的比对基线) before_contacts = normalize_contacts((profile.raw_info or {}).get("联系方式")) new_info = await self.ai.extract_info( full_history, existing_info=profile.raw_info or {} ) logger.info("Extractor result: %s", new_info) if new_info: raw = dict(profile.raw_info or {}) for k, v in new_info.items(): # 联系方式深合并(同key新值覆盖),其余顶层覆盖 —— 堵浅合并抹数据 if k == "联系方式": raw[k] = merge_contacts(raw.get(k), v) else: raw[k] = v profile.raw_info = raw self.db.add(profile) # Round 2.5: 联系方式正则辅源(S4: 扫全量客户消息历史) # 🔴 合并方向: 扫描器只填空,extractor 已有值优先——否则扫描器的 # 首个历史值会压掉 extractor 后续捕获的改号, D4 更正邮件失效 scanned = scan_user_messages(full_history) if scanned: raw = dict(profile.raw_info or {}) raw["联系方式"] = merge_contacts(scanned, raw.get("联系方式")) profile.raw_info = raw self.db.add(profile) # Task 12: 最新显式自述优先 —— 唯一允许文本压过 extractor 回传的位置 # (extractor 受"保留已有信息"倾向会滞留旧值,实测 wrong number 场景命中) latest_stated = latest_stated_contacts(full_history) if latest_stated: current = normalize_contacts((profile.raw_info or {}).get("联系方式")) changed = False for k, v in latest_stated.items(): if v and canon(str(v)) != canon(str(current.get(k, ""))): current[k] = v changed = True if changed: raw = dict(profile.raw_info or {}) raw["联系方式"] = current profile.raw_info = raw self.db.add(profile) # Task 12c 最近提及裁决: 镜像组分歧时以客户最晚提及的号码为准 # (杀 extractor 历史振荡 + 压制幻觉;Task 12b 同号同步场景被涵盖) after_contacts = normalize_contacts((profile.raw_info or {}).get("联系方式")) resolved = resolve_by_recency(before_contacts, after_contacts, full_history) if resolved != after_contacts: raw = dict(profile.raw_info or {}) raw["联系方式"] = resolved profile.raw_info = raw self.db.add(profile) # Round 3: Evaluate (仅 ≥3 条客户消息才有意义, 前两轮必然 pending —— D7) user_msg_count = sum(1 for m in full_history if m["role"] == "user") evaluation_ran = user_msg_count >= 3 if evaluation_ran: evaluation = await self.ai.evaluate( profile=profile.raw_info or {}, recent_messages=full_history, ) else: # 客户端 evaluated 事件语义保持 pending;邮件标签由 evaluation_ran 决定(D2) evaluation = {"intent_level": "pending", "reasoning": "", "contact_info": {}, "info_sufficient": False} logger.info("Evaluation (stream): profile=%s | eval=%s", profile.raw_info, evaluation) # 邮件触发(D1): 与会话结束彻底解耦,联系方式到手即发 pending_pushes: list[LeadPushLog] = [] await self._maybe_send_lead_email( conv, profile, evaluation, evaluated=evaluation_ran, pending_pushes=pending_pushes, ) # 联系方式判定(供 D8 评估落库): 有意向但没联系方式 → 卡单供 admin 人工兜底 confirmed_now, _ = split_validate( normalize_contacts((profile.raw_info or {}).get("联系方式"))) # 评估落库(D8 泛化): 每轮评估后刷新意向;email_sent/correction_count # 永不触碰。有意向无联系方式 = 卡单(admin 可筛);已发过邮件的 # contact_info 由触发器更正分支管理,这里不覆盖。 if user_msg_count >= 3: ev = await self._get_or_init_evaluation(conv.id) ev.intent_level = IntentLevel(evaluation.get("intent_level", "pending")) ev.reasoning = evaluation.get("reasoning", "") if not confirmed_now: ev.contact_info = {} elif not ev.email_sent: ev.contact_info = confirmed_now self.db.add(ev) # 决策A(2026-08-18): 自动结束移除——会话永不因评估结果结束; # evaluated 事件保留但 ended 恒为 False(客户端协议不变)。 await self.db.commit() # 询盘推送(设计 8.1): 主事务提交后才派生后台任务——payload 内存 # 传递,任务不读库只写结果;fire 为同步函数,纳秒级返回不阻塞 lead_push_service.fire_pending_pushes(pending_pushes) yield { "type": "evaluated", "ended": False, "intent_level": evaluation.get("intent_level", "pending"), } async def _get_or_init_evaluation(self, conv_id: str) -> Evaluation: result = await self.db.execute( select(Evaluation).where(Evaluation.conversation_id == conv_id) ) ev = result.scalar_one_or_none() if not ev: ev = Evaluation( conversation_id=conv_id, intent_level=IntentLevel.pending, contact_info={}, ) self.db.add(ev) await self.db.flush() return ev async def _recently_emailed_canons(self) -> set[str]: cutoff = datetime.now(timezone.utc) - timedelta(hours=24) # 只取需要的列;🔴 禁止加 LIMIT——完整 24h 窗口才正确 result = await self.db.execute( select(Evaluation.contact_info).where( Evaluation.email_sent.is_(True), Evaluation.evaluated_at >= cutoff, ) ) seen: set[str] = set() for (contact_info,) in result: for v in (contact_info or {}).values(): if v: seen.add(canon(str(v))) return seen async def _maybe_send_lead_email( self, conv: Conversation, profile: CustomerProfile, evaluation: dict, evaluated: bool = True, pending_pushes: list[LeadPushLog] | None = None, ) -> None: """D1: 联系方式是发邮件的唯一条件。confirmed 才触发;suspect 只进提醒。 evaluated=False 表示本轮跳过了 GLM 评估(<3条客户消息)——此时邮件 intent_level 必须传空串(→[未评估]),不得把客户端事件用的 "pending" 透传成 [待确认](D2 两者语义不同)。 """ contacts_all = normalize_contacts((profile.raw_info or {}).get("联系方式")) confirmed, _suspect = split_validate(contacts_all) if not confirmed: return ev = await self._get_or_init_evaluation(conv.id) if not ev.email_sent: # S3: 任一值已见于24h内其他会话 → 轻提醒;否则全量 seen = await self._recently_emailed_canons() if any(canon(str(v)) in seen for v in confirmed.values()): await self._collect_push( pending_pushes, ev, conv, profile, confirmed, "returning", evaluation, evaluated) sent = await email_service.send_returning_lead_email(confirmed) else: await self._collect_push( pending_pushes, ev, conv, profile, confirmed, "lead", evaluation, evaluated) messages = await self._load_history(conv.id) sent = await email_service.send_lead_email( customer_profile=profile.raw_info or {}, contact_info=confirmed, reasoning=evaluation.get("reasoning", ""), conversation_messages=messages, intent_level=evaluation.get("intent_level", "") if evaluated else "", ) if sent: ev.email_sent = True ev.contact_info = confirmed self.db.add(ev) else: old_canons = {canon(str(v)) for v in (ev.contact_info or {}).values()} if any(canon(str(v)) not in old_canons for v in confirmed.values()): if ev.correction_count < 3: count = ev.correction_count + 1 await self._collect_push( pending_pushes, ev, conv, profile, confirmed, "correction", evaluation, evaluated) if await email_service.send_correction_email(confirmed, count=count): ev.correction_count = count ev.contact_info = confirmed self.db.add(ev) else: # D4'(2026-08-18): 锁邮件不锁快照 —— 上限已到, # admin 仍要看到客户最新号码 ev.contact_info = confirmed self.db.add(ev) async def _collect_push( self, pending, ev, conv, profile, confirmed, push_type, evaluation, evaluated, ) -> None: """询盘推送派发(设计 §8): 触发条件=进入发信分支,与 SMTP 成败解耦。 dispatch 内部吞异常(失败只记日志),这里无需再兜。 """ log = await lead_push_service.dispatch_lead_push( self.db, ev, conv, profile, confirmed, push_type, evaluation, evaluated, ) if log is not None and pending is not None: pending.append(log) async def _load_history(self, conv_id: str) -> list[dict]: result = await self.db.execute( select(Message) .where(Message.conversation_id == conv_id) .order_by(Message.created_at) ) messages = result.scalars().all() return [{"role": m.role.value, "content": m.content} for m in messages] async def _get_profile(self, conv_id: str) -> CustomerProfile: result = await self.db.execute( select(CustomerProfile).where(CustomerProfile.conversation_id == conv_id) ) return result.scalar_one() @staticmethod def _enrich_short_message(content: str, raw_info: dict, history: list[dict] | None = None) -> str: """Enrich short user messages with conversation context. Two sources: extractor output (primary), last substantive user message (fallback when extractor hasn't run yet). """ if len(content.strip()) >= 30: return content facts = [] # Primary: extractor output if raw_info: facts = [f"{k}: {v}" for k, v in raw_info.items() if v and not str(k).startswith("_") and k != "联系方式"] # Fallback: use last substantive user message as context if not facts and history: for msg in reversed(history[:-1]): if msg.get("role") == "user" and len(msg.get("content", "").strip()) >= 5: facts.append("客户之前说: " + msg["content"][:80]) break if not facts: return content return ( content + "\n\n【系统上下文 - 系统标注,不要向客户提及】已知: " + " | ".join(facts) + "。不要重复询问这些已确认的信息!" ) @staticmethod def _build_context_reminder(raw_info: dict, latest_msg: str, history: list[dict] | None = None, source: str = "") -> str: """Build a context reminder from extracted profile info.""" # 职能边界提醒(评审#7: 前置到早退之前,首条消息画像为空也要生效) boundary_note = "" if BOUNDARY_TRIGGER_RE.search(latest_msg or ""): if BUDGET_TRIGGER_RE.search(latest_msg or ""): # 评审#9: 预算求推荐 → 预算路径(推荐+反问+引导),不一刀切要号 boundary_note = ( "⚠️ 职能边界提醒:客户报预算求推荐。推荐产品方案但绝不复述预算数字、" "不报价格 → 反问意向产品与需求规格 → 顺势引导留联系方式。") else: boundary_note = ( "⚠️ 职能边界提醒:客户正在询问价格/优惠/交付时效/实质安排。绝对禁止" "给出任何价格数字(包括复述客户自己的预算)、交付时间或任何承诺," "按职能边界对应话术处理。") # 联系方式状态前移(供 boundary/contact 提醒共用判定) contacts_all = normalize_contacts(raw_info.get("联系方式")) confirmed, suspect = split_validate(contacts_all) # 索要我方联系方式提醒(15.2): 与 boundary_note 同样前移到早退之前, # 空画像首条消息也要生效;按 source 分支(widget/standalone 有按钮, app 无)。 # 冒烟实证: 抽象指令("用App话术")GLM 会无视并背 prompt 里更显眼的 web 脚本—— # 提醒必须携带该分支的逐字话术,不让模型自己挑。 contact_note = "" if CONTACT_ASK_TRIGGER_RE.search(latest_msg or ""): if source == "app": if confirmed or suspect: # 已留资: 索要我方联系方式 → 逐字"经理主动联系"版, # 与"禁止再索要"提醒不再打架 contact_note = ( "⚠️ 客户正在索要我方联系方式,且客户已留过自己的联系方式。" "请逐字使用这句:\"Our manager will reach out to you at your " "WhatsApp shortly with the full quote!\"(中文同理)。" "绝对禁止输出任何号码或邮箱,也绝对禁止再索要联系方式。") else: contact_note = ("⚠️ 客户正在索要我方联系方式(最高意向信号)。当前为 App 会话," "没有页面按钮——禁止使用任何 tap/button 按钮话术!请逐字使用这句:" "\"Drop your WhatsApp and our manager will reach out to you directly " "with the full quote!\"(中文同理引导客户留联系方式)。" "绝对禁止输出任何号码或邮箱。") else: contact_note = ("⚠️ 客户正在索要我方联系方式(最高意向信号)。请逐字使用这句指路:" f"\"{NO_CONTACT_WEB}\"(邮箱同理指 Email 按钮)。" "绝对禁止口头报出任何号码或邮箱。") # 语言跟随提醒(Task 11): 与 boundary/contact 提醒同挂载位—— # 首条消息画像为空也要生效(中文客户开场即漂英文是实测重灾区) lang_note = _lang_note_for(latest_msg) if not raw_info: # 空画像:仅返回边界/联系/语言提醒(可能为空串) return "\n".join(x for x in (boundary_note, contact_note, lang_note) if x) parts = ["[上下文提醒 - 这是一条系统指令,绝对不要向客户提及这段内容]"] parts.append("客户已明确告知以下信息,你绝对不能再问这些:") for key, val in raw_info.items(): if str(key).startswith("_") or key == "联系方式": continue # 内部状态与联系方式单独渲染 if val: parts.append(f"{key}: {val}") # 联系方式专项(D6) 与 职能边界(D6联动) if confirmed: shown = ", ".join( f"{k}: {' '.join(str(v).split())[:60]}" for k, v in confirmed.items()) # 逐字话术直接引用 NO_CONTACT_ALREADY 常量(Task 5 已 import)—— # 与零号码墙替换句同源,三处"经理主动联系"话术不漂移 parts.append(f"⚠️ 客户已提供联系方式({shown})。绝对禁止再索要任何联系方式!" f"需要回应时可直接逐字使用:\"{NO_CONTACT_ALREADY}\"(中文同理)。" f"确认收到后继续解答客户问题即可,不必急着结束对话。") if boundary_note: parts.append(boundary_note) parts.append("客户已留过联系方式——边界请求直接告知'客户经理会把准确报价/" "安排发到你 WhatsApp'即可,禁止再索要联系方式。") elif suspect: shown = ", ".join( f"{k}: {' '.join(str(v).split())[:60]}" for k, v in suspect.items()) parts.append(f"⚠️ 客户疑似已提供联系方式({shown})。禁止重新索要;" f"可礼貌地请客户确认一次。") if boundary_note: parts.append(boundary_note) if boundary_note and not confirmed and not suspect: parts.append(boundary_note) # 未留资:边界提醒引导留资(职能边界话术自带) # 15.2: 索要我方联系方式提醒注入(镜像 boundary_note 的挂载位)。 # 互留提示仅限 web 来源且客户未留资(confirmed/suspect 都算"已留"); # App 的反向留资话术本身就是主话术,不再叠加 if contact_note: note = contact_note if source != "app" and not confirmed and not suspect: note += "客户尚未留过联系方式,可顺势轻问一句(每会话最多一次)。" parts.append(note) if len(latest_msg.strip()) < 25: parts.append("⚠️ 客户发的是短消息!必须结合上面已知信息来理解,绝对不能当作独立问题!") if lang_note: parts.append(lang_note) return "\n".join(parts)