feat: 判定树轮前接入+cc_reply消费+出口卫生+存量迁移

Task 3(cc-completion 计划最重集成项):
- 轮前钩子 _cc_pre_turn: effective_raw 建成后、reminder 之前跑判定树,
  建/重置/迁移 _cc(同 canon 保留状态,canon 变化即 RESET 重走);
  与一次性计数写回同事务落库(条件扩为计数或 _cc 变更任一存在)
- 轮末钩子 _cc_round_end: 四层联系方式合并后、D1 邮件前——双门控消费
  cc_reply(仅 whatsapp 等待态;落定态仅开迟到 country 升级口,no_ip
  耗除外),canon 变化 RESET,末次联系方式补建 pass
- 出口卫生三封堵: cc_reply 消费后剥离/extractor 回传 _ 前缀键防御性
  丢弃/入口 existing_info 滤 _ 复核
- 存量迁移: 旧 _cc_asked=True+裸 whatsapp 无 _cc → 一次性 fallback 态
- _contact_completion_note cc 分支删除,邮箱轻补解锁条件改
  cc_settled(_cc.whatsapp);cc 墙门控改线 cc_due(_wa_cc)
- 集成实证修两处数据流 bug: WHATSAPP_CTX_RE 间隙吞 + 号(显式国际
  前缀被捕成裸号→误追问);同轮扫描合并方向反转(存量覆盖当轮新号,
  轮前 canon 变化检测失效——与轮末最新自述优先同语义对齐)
- 新增 10 项集成测试(affirm 确认/换号重走/迟到报国升级与忽略/
  卫生三封堵/存量迁移/confirmed 态忽略/同 canon 组归并/异号 phone
  静默/纯 phone 无墙),282 项全绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-08 18:06:39 +08:00
parent c13585b4a6
commit 0b5c219090
3 changed files with 549 additions and 113 deletions
+218 -67
View File
@@ -12,7 +12,9 @@ from app.models.database import (
Message, MessageRole, CustomerProfile, Evaluation, IntentLevel,
LeadPushLog,
)
from app.services import email_service, lead_push_service
from app.services import (
cc_completion, email_service, ip_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,
@@ -20,8 +22,8 @@ from app.services.boundary_filter import (
violates_boundary,
)
from app.services.contact_pipeline import (
canon, latest_stated_contacts, merge_contacts, normalize_contacts,
resolve_by_recency, scan_user_messages, split_validate,
canon, digits_only, latest_stated_contacts, merge_contacts,
normalize_contacts, resolve_by_recency, scan_user_messages, split_validate,
)
logger = logging.getLogger(__name__)
@@ -98,9 +100,11 @@ def _lang_note_for(msg: str) -> str:
# Task 3(spec §4.1-2/§4.4, 2026-10-08): 缺失渠道轻补逐字模板——与 system.md
# 联系方式引导策略节同源。注入的是"缺失渠道"(客户没给过的),与再索要墙的
# 渠道精确化(Task 1)配套: 墙只拦已在手渠道的索要,这些话术恰好问缺失渠道。
# CC 强制墙(2026-10-08 增补设计 §2)复用同源正文: _CC_ASK_* 是注入指引里
# "请逐字使用这句"的正文;_CC_WALL_* 是墙的整段替换句(EN 加 "Got it — "
# 衔接头,设计 §2 定稿文案;ZH 与正文同句)——两处逐字锚点不漂移。
# CC 强制墙(2026-10-08 增补设计 §2)复用同源正文: _CC_ASK_* 是追问句正文;
# _CC_WALL_* 是墙的整段替换句(EN 加 "Got it — " 衔接头;ZH 与正文同句)。
# 2026-10-08 判定树设计 Task 3: cc 轻补注入分支已删(判定树接管),_CC_NOTE_*
# 不再运行时注入——常量保留作 no_ip 路径/Task 4 回显模板的正文基准
# (test_boundary_filter / test_system_prompt_dual_contact 仍锚定其正文)。
_CC_ASK_EN = (
"Could you send the full number with the country code, "
"like +966 5x xxx xxxx? Just so our manager can reach you directly.")
@@ -132,15 +136,14 @@ def _contact_completion_note(
raw_info: dict | None, confirmed: dict | None, suspect: dict | None,
lang: str = "en",
) -> tuple[str, list[str]]:
"""一次性缺失渠道轻补提醒(spec §4.2/§4.3/§4.3b)。纯函数,返回(注入文案,计数键)。
"""邮箱轻补提醒(spec §4.2/§4.3b,2026-10-08 判定树设计收编)。
纯函数,返回(注入文案,计数键)。
串行优先级(§4.3b——两提醒同轮并发时一次只注入一个,顺序固定):
1. cc 优先: confirmed 号码渠道(whatsapp/phone)均有值且均不以 +/00 开头、
_cc_asked 未标记 → 国家代码追问(引导重发完整号,走改号覆盖机制,
管道零改动)
2. email 轻补: (任一号码已带前缀 或 _cc_asked 已消耗)且 confirmed 含号码
渠道、不含 email、_email_asked 未标记 → 邮箱轻补
3. 其余(双渠道齐/仅 email/两计数均耗尽/confirmed 无号码渠道) → ("", [])
cc 追问分支已删除——号码区号补全由轮前判定树钩子接管(_cc 状态机,
_cc_pre_turn),确认提醒由 cc_due 驱动(Task 4 接)。本函数只管邮箱轻补,
且串行解锁条件改为 cc_settled(_cc.whatsapp): 确认流未落定时注入邮箱
轻补会让两个"逐字使用这句"同轮竞争(旧 §4.3b 场景①教训);落定即解锁。
带 + 号客户无 _cc 状态 → cc_settled(None)=True,旧轻补行为不回归。
suspect 仅为与调用处画像判定同签名,规则只看 confirmed(spec §4.2)。
"""
info = raw_info or {}
@@ -148,14 +151,74 @@ def _contact_completion_note(
if k in ("whatsapp", "phone") and v]
if not numbers:
return "", []
all_bare = all(not v.startswith(("+", "00")) for v in numbers)
if all_bare and not info.get("_cc_asked"):
return (_CC_NOTE_ZH if lang == "zh" else _CC_NOTE_EN), ["_cc_asked"]
if (not all_bare or info.get("_cc_asked")) \
and "email" not in (confirmed or {}) \
and not info.get("_email_asked"):
return (_EMAIL_NOTE_ZH if lang == "zh" else _EMAIL_NOTE_EN), ["_email_asked"]
return "", []
if "email" in (confirmed or {}):
return "", []
if info.get("_email_asked"):
return "", []
if not cc_completion.cc_settled((info.get("_cc") or {}).get("whatsapp")):
return "", [] # whatsapp 确认流未落定: 邮箱轻补串行等待
return (_EMAIL_NOTE_ZH if lang == "zh" else _EMAIL_NOTE_EN), ["_email_asked"]
def _cc_pre_turn(raw_info: dict, confirmed: dict, ip_iso: str | None) -> bool:
"""轮前判定树钩子(spec §6-1/§3/§4): 对 confirmed 号码建/重置/迁移 _cc。
- 多渠道策略: 仅 whatsapp 组走确认流;phone 与 whatsapp 同 canon 时共享
whatsapp 组状态(不建独立键),异号时 phone 组只记静默补全态
(ip_proposed/naive/no_ip,不进确认流)——确认流的消费与门控
(cc_due/cc_settled/轮末 transit)一律只看 _cc.whatsapp,phone 态
仅供出站渲染(Task 6)。
- canon 变化即重置重走判定树(旧补全永不跟随新号);同 canon 保留原状态
(attempts 只被 canon 变化重置,spec §4)。
- 显式 +/00 号码不建状态(无态=天然落定);prefix_normalized 静默规范化
态(confirmed=true)照写,供出站渲染无标注输出。
- 存量迁移(spec §4 尾): 旧 _cc_asked=True 且 whatsapp 裸号尚无 _cc 组 →
一次性落 fallback 态(已问过不再追问,直通邮箱轻补);判定树算出的
e164 建议保留(首封可拨)。_cc 组一旦存在即不再触发。
就地更新 raw_info["_cc"],返回是否有变更(调用方与计数写回同事务落库)。
"""
before = raw_info.get("_cc")
cc = dict(before or {})
migrate = bool(raw_info.get("_cc_asked"))
wa = str(confirmed.get("whatsapp") or "").strip()
ph = str(confirmed.get("phone") or "").strip()
def _refresh(channel: str, value: str) -> None:
existing = cc.get(channel)
if existing is not None \
and str(existing.get("canon") or "") == digits_only(value):
return # 同 canon: 保留原状态(attempts 不重置)
kind, state = cc_completion.classify(value, ip_iso)
if state is None:
cc.pop(channel, None) # 显式 +/00(或防御性垃圾输入): 无态即落定
return
st = dict(state)
if (migrate and channel == "whatsapp" and existing is None
and kind == cc_completion.NEED_CC):
st["confirmed"] = False
st["source"] = "fallback" # 存量迁移: 已问过,不再追问
st.pop("suggest_ask_full", None)
cc[channel] = st
if wa:
_refresh("whatsapp", wa)
else:
cc.pop("whatsapp", None) # 号码不再在手: 状态清,防残留跟随
if ph:
wa_state = cc.get("whatsapp")
if wa_state is not None \
and str(wa_state.get("canon") or "") == digits_only(ph):
cc.pop("phone", None) # 同 canon 组归并: phone 共享 whatsapp 状态
else:
_refresh("phone", ph)
else:
cc.pop("phone", None)
if cc:
raw_info["_cc"] = cc
else:
raw_info.pop("_cc", None)
return raw_info.get("_cc") != before
# 方案B(13.6): 迂回话术轮换序号——连续破防时不至于复读同一句
@@ -247,40 +310,64 @@ class ConversationManager:
profile = await self._get_profile(conv.id)
# P0 同轮竞态消除: 当前消息即时正则扫描——仅并入本轮内存视图
# (提醒/话术路由),不落库;持久化仍由轮末 extractor+scanner 负责,
# 邮件触发时序不变。
# (提醒/话术路由/轮前 cc 判定树),不落库;持久化仍由轮末
# extractor+scanner 负责,邮件触发时序不变。
# 合并方向(Task 3 修正): 当前消息捕获覆盖存量——客户当轮改号时,
# 同轮视图必须看到新号(轮前判定树 canon 变化即重置依赖此方向;
# 与轮末 Task 12 "最新显式自述优先"同语义)。存量只在当前消息
# 未提及的渠道上补位。
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("联系方式"))
effective_raw.get("联系方式"), current_scan)
# 联系方式视图(轮前钩子/计数写回/再索要墙区共用;原在 reminder 之后
# 计算,前移只为喂轮前钩子,输入相同取值不变)
_contacts_now = normalize_contacts(effective_raw.get("联系方式"))
_confirmed_now, _suspect_now = split_validate(_contacts_now)
# Task 3 轮前判定树钩子(spec §6-1;评审 Step 3b 钉死顺序: 必须在
# _build_context_reminder 之前)——即时扫描并入裸号后同步跑判定树
# (纯计算毫秒级)建/重置/迁移 _cc,当轮即可被墙门控/轮末消费/出站
# 渲染读到;落库与下方一次性计数写回同事务。
_ip_iso = ip_service.resolve_region_code(
(conv.client_info or {}).get("ip"))
_cc_changed = _cc_pre_turn(effective_raw, _confirmed_now, _ip_iso)
_wa_cc = (effective_raw.get("_cc") or {}).get("whatsapp")
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)
# Task 3(spec §4.3): 一次性计数写回——_build_context_reminder 是
# staticmethod 收内存副本,计数键须由调用方在流式开始前显式落库;
# 轮末 extractor 合并起点是 dict(profile.raw_info or {}),提前提交可存活。
# 与 reminder 内部同参调用(上方纯函数),保证写回与注入永不漂移。
_contacts_now = normalize_contacts(effective_raw.get("联系方式"))
_confirmed_now, _suspect_now = split_validate(_contacts_now)
# Task 3(spec §4.3/§6-1): 一次性计数写回 + _cc 状态同事务落库——
# _build_context_reminder 是 staticmethod 收内存副本,计数键/状态须由
# 调用方在流式开始前显式落库;轮末 extractor 合并起点是
# dict(profile.raw_info or {}),提前提交可存活。与 reminder 内部同参
# 调用(上方纯函数),保证写回与注入永不漂移;条件扩为"计数或 _cc
# 变更任一存在"(评审 Step 3b)。
_, completion_marks = _contact_completion_note(
effective_raw, _confirmed_now, _suspect_now,
"zh" if _CJK_RE.search(content or "") else "en")
if completion_marks:
if completion_marks or _cc_changed:
raw = dict(profile.raw_info or {})
for key in completion_marks:
raw[key] = True
if _cc_changed:
if effective_raw.get("_cc"):
raw["_cc"] = effective_raw["_cc"]
else:
raw.pop("_cc", None)
profile.raw_info = raw
self.db.add(profile)
await self.db.commit()
# warning 级(2026-10-08 第四轮验收): 生产曾现 2/5 裸号轮墙未介入的
# 非确定性异常(同输入容器内链路复现正常)——本行生产可见,异常会话
# 可立刻分辨"计数/注入是否发生",供观察期定位
logger.warning("Contact completion note injected (conv=%s): %s",
conv.id, completion_marks)
# 可立刻分辨"计数/状态/注入是否发生",供观察期定位
logger.warning("Contact completion state persisted (conv=%s): "
"marks=%s cc_changed=%s",
conv.id, completion_marks, _cc_changed)
# Enrich short user messages with context so AI doesn't lose track
history_for_ai = list(history)
@@ -297,11 +384,14 @@ class ConversationManager:
# (confirmed/suspect 已在 Task 3 写回处算好,此处复用)
has_contact = bool(_confirmed_now)
_confirmed_channels = set(_confirmed_now)
# CC 强制墙(2026-10-08 增补设计 §2): 本轮注入的是国家代码追问时,
# 输出侧确定性强制。流式无法撤回 → 触发轮不逐句流出,整段缓冲到
# 流末统一判 "country code" 在场与否(缓冲期间价格/零号码/再索要
# 三墙照常逐句检查,优先级不变)。提示词层全部保留,墙只是兜底。
cc_wall = completion_marks == ["_cc_asked"]
# CC 强制墙(2026-10-08 cc 设计): 门控改线判定树——cc_due(_cc.whatsapp)
# 等待态即触发(旧 `completion_marks == ["_cc_asked"]` 门控随 cc 轻补
# 分支删除而消亡,_cc_asked 停写)。流式无法撤回 → 触发轮不逐句流出,
# 整段缓冲到流末统一判 "country code" 在场与否(缓冲期间价格/零号码/
# 再索要三墙照常逐句检查,优先级不变)。no_ip 态(无可回显号)沿用
# 本套 ASK 模板与检测词;有建议国态的回显确认模板/e164 检测词由
# Task 4 接管。
cc_wall = cc_completion.cc_due(_wa_cc)
yield {"type": "stream_start"}
emitted = ""
@@ -398,13 +488,13 @@ class ConversationManager:
for i in range(0, len(closing), 40):
yield {"type": "stream_chunk", "content": closing[i:i + 40]}
emitted += closing
# CC 强制墙裁决(2026-10-08 增补设计 §2): 触发轮缓冲全文在此判定——
# 含 "country code"(中文轮认 "国家代码",与 _CC_NOTE_ZH 注入的逐字
# 追问句同词)→ GLM 自发服从,缓冲原样流出,零干预;不含 → 整段替换
# 为 CC 模板句正文。violated(价格/零号码墙)优先于本墙——沿用双墙
# 裁决"更具体话术优先"惯例;_cc_asked 已在流前落库,一次性消耗不受
# 替换影响。缓冲轮全部实义句被再索要墙丢弃时 cc_buffer 为空 → 同样
# 走替换(模板句即本轮唯一实义输出,天然不触发下方兜底)。
# CC 强制墙裁决(2026-10-08 cc 设计): 触发轮缓冲全文在此判定——
# 含 "country code"(中文轮认 "国家代码",与 ASK 模板句同词)→ GLM
# 自发服从,缓冲原样流出,零干预;不含 → 整段替换为 CC 模板句正文。
# violated(价格/零号码墙)优先于本墙——沿用双墙裁决"更具体话术优先"
# 惯例;_cc 状态已在流前落库,不受替换影响。缓冲轮全部实义句被再索要
# 墙丢弃时 cc_buffer 为空 → 同样走替换(模板句即本轮唯一实义输出,
# 天然不触发下方兜底)。
if cc_wall and not violated:
cc_full = "".join(cc_buffer)
if "country code" in cc_full.lower() or "国家代码" in cc_full:
@@ -450,7 +540,7 @@ class ConversationManager:
# Task 12b: 轮前联系方式快照(extractor 深合并 + 覆盖 + 同号同步的比对基线)
before_contacts = normalize_contacts((profile.raw_info or {}).get("联系方式"))
# _ 前缀内部键(_cc_asked 等一次性计数)不进 LLM 输入(Task 3 审查
# _ 前缀内部键(_cc 状态机/_email_asked 计数)不进 LLM 输入(Task 3 审查
# Minor 3 防御纵深——渲染/推送侧已过滤,此处防提示词侧意外回显)
new_info = await self.ai.extract_info(
full_history,
@@ -458,6 +548,21 @@ class ConversationManager:
if not str(k).startswith("_")},
)
logger.info("Extractor result: %s", new_info)
# 出口卫生(spec §4 三封堵之②/③): cc_reply 是 extractor 的单轮瞬态
# 分类,提取后即剥离——绝不进 raw_info 持久层(消费点在轮末钩子
# _cc_round_end);同时防御性丢弃 extractor 回传的任何 `_` 前缀键,
# 防 LLM 回显 _cc 覆盖 Python 管理的状态机。(入口侧封堵①: 上方
# existing_info 已滤 `_`,复核通过)
cc_reply = None
if isinstance(new_info, dict):
cc_reply = new_info.pop("cc_reply", None)
stripped = {k: v for k, v in new_info.items()
if not str(k).startswith("_")}
if len(stripped) != len(new_info):
logger.warning(
"Dropped underscore keys from extractor output (conv=%s): %s",
conv.id, [k for k in new_info if str(k).startswith("_")])
new_info = stripped
if new_info:
raw = dict(profile.raw_info or {})
for k, v in new_info.items():
@@ -505,6 +610,11 @@ class ConversationManager:
profile.raw_info = raw
self.db.add(profile)
# Task 3 轮末 cc 钩子(spec §6-2): 联系方式四层合并完成后、D1 邮件
# 触发前——消费 cc_reply 推进状态机,并为本轮才入管道的号码补建 _cc
# (spec §5: 首封补全号/推送派生依赖已建好的 _cc)。
await self._cc_round_end(conv, profile, cc_reply)
# 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
@@ -590,6 +700,56 @@ class ConversationManager:
seen.add(canon(str(v)))
return seen
async def _cc_round_end(
self, conv: Conversation, profile: CustomerProfile,
cc_reply: dict | None,
) -> None:
"""轮末 cc 钩子(spec §6-2): 联系方式四层合并后、D1 邮件前调用。
- cc_reply 消费(双门控 Python 侧): 仅 whatsapp 组等待态(cc_due)消费
——无回应/漏填也推进(attempts 随轮递增,达上限落定 fallback);
落定态仅开迟到 country 升级口: 客户主动报国(非空 country)且
source 非 no_ip 耗尽 → transit 重算 e164(客户主动给的信息不
丢弃);迟到 affirm 不升级(对已标"未经确认"旧号的确认语义模糊,
confirmed 态更不重算——客户改主意请重发完整号,走 canon 重置)。
- canon 变化(换号/号消失)→ RESET 删组状态。
- 补建 pass: 对最终联系方式重跑判定树(_cc_pre_turn)——本轮才经
extractor 入管道的裸号/换号后的新号当场建状态,不下轮补课。
"""
original_cc = (profile.raw_info or {}).get("_cc")
ip_iso = ip_service.resolve_region_code(
(conv.client_info or {}).get("ip"))
raw = dict(profile.raw_info or {})
confirmed_end, _ = split_validate(normalize_contacts(raw.get("联系方式")))
wa_canon_end = digits_only(str(confirmed_end.get("whatsapp") or ""))
cc_map = dict(raw.get("_cc") or {})
state = cc_map.get("whatsapp")
reply = None
if isinstance(cc_reply, dict):
country = str(cc_reply.get("country") or "").strip()
if cc_reply.get("affirm") is not None or country:
if cc_completion.cc_due(state):
reply = cc_reply
elif (state is not None and not state.get("confirmed")
and country and state.get("source") != "no_ip"):
reply = cc_reply # 迟到 country 升级口
if state is not None:
if not wa_canon_end or wa_canon_end != str(state.get("canon") or ""):
cc_map.pop("whatsapp", None) # 换号/号消失 → RESET
elif reply is not None or cc_completion.cc_due(state):
cc_map["whatsapp"] = cc_completion.transit(state, reply)
if cc_map:
raw["_cc"] = cc_map
else:
raw.pop("_cc", None)
_cc_pre_turn(raw, confirmed_end, ip_iso)
if raw.get("_cc") != original_cc:
profile.raw_info = raw
self.db.add(profile)
async def _maybe_send_lead_email(
self, conv: Conversation, profile: CustomerProfile, evaluation: dict,
evaluated: bool = True,
@@ -783,11 +943,12 @@ class ConversationManager:
if val:
parts.append(f"{key}: {val}")
# Task 3(spec §4.2/§4.3b): 缺失渠道一次性轻补——cc 优先、邮箱次之,串行
# 注入。计数键(_marks)此处弃用: 本函数是 staticmethod 收内存副本,写回由
# stream 调用方以同参调用纯函数取回并落库(纯函数保证两处判定不漂移)。
# 计算前移到 confirmed 块之前: CC 追问轮 confirmed 块的确认句要让位。
completion_note, completion_marks = _contact_completion_note(
# Task 3(spec §4.2/§4.3b): 邮箱轻补注入(cc 分支已删,判定树接管号码
# 区号补全——确认提醒由 cc_due 驱动,Task 4 接)。计数键(_marks)此处
# 弃用: 本函数是 staticmethod 收内存副本,写回由 stream 调用方以同参
# 调用纯函数取回并落库(纯函数保证两处判定不漂移)。计算前移到
# confirmed 块之前: 未落定轮邮箱轻补不注入(cc_settled 串行)。
completion_note, _marks = _contact_completion_note(
raw_info, confirmed, suspect,
lang="zh" if _CJK_RE.search(latest_msg or "") else "en")
@@ -802,19 +963,8 @@ class ConversationManager:
parts.append(f"⚠️ 客户已提供联系方式({shown})。绝对禁止再索要客户已提供过"
f"的联系方式;缺失渠道仅可按轻补指引提一次;号码缺国家代码"
f"时可请客户重发完整号一次。")
if completion_marks == ["_cc_asked"]:
# 生产验收场景①修复(2026-10,三轮): CC 追问是对已在手渠道的重发
# 请求,不在"缺失渠道"例外内——同轮两个"逐字使用这句"竞争时 GLM
# 稳定服从更显眼的确认句,压制追问。本轮 confirmed 块让位: 唯一
# 逐字锚点=下方轻补指引的追问句;禁数字回显堵住"让位后 GLM 自创
# 确认句带出真实号码"的回归(墙的客户回显豁免会放行)
parts.append("本轮不要使用确认句,请逐字使用下面这条国家代码追问句请客户"
"重发带国家代码的完整号码(仅此一次,客户重发或明确不给后"
"下一轮恢复正常)。回复中绝不写出号码数字——提到号码一律用 "
"your WhatsApp 指代。")
else:
parts.append(f"需要回应时可直接逐字使用:\"{NO_CONTACT_ALREADY}\"(中文同理)。"
f"确认收到后继续解答客户问题即可,不必急着结束对话。")
parts.append(f"需要回应时可直接逐字使用:\"{NO_CONTACT_ALREADY}\"(中文同理)。"
f"确认收到后继续解答客户问题即可,不必急着结束对话。")
if boundary_note:
parts.append(boundary_note)
# Task 3 防自锁改写: 尾部补缺失渠道豁免(与上方改写同因)
@@ -833,7 +983,8 @@ class ConversationManager:
if boundary_note and not confirmed and not suspect:
parts.append(boundary_note) # 未留资:边界提醒引导留资(职能边界话术自带)
# Task 3 轻补注入(计算已前移到 confirmed 块之前——CC 轮让位判定需要)
# Task 3 轻补注入(计算已前移到 confirmed 块之前;cc_settled 未落定时
# note 为空,天然不注入)
if completion_note:
parts.append(completion_note)