chore: 从汽车AI客服项目拷贝骨架并清理(blog页/redis)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-29 14:15:12 +08:00
parent 90860b8bfd
commit 49c0e5559c
97 changed files with 19131 additions and 0 deletions
@@ -0,0 +1,627 @@
# 自动弹出邀请卡片实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**目标:** 为 Widget 模式添加自动弹出邀请卡片,页面加载 1.8 秒后显示,提供快速访问热门话题的入口
**架构:** 在现有 widget.js 中扩展,使用 Shadow DOM 隔离样式,sessionStorage 管理每页独立的关闭状态
**技术栈:** 原生 JavaScript、Shadow DOM、sessionStorage
---
## 文件结构
**修改文件:**
- `static/widget.js` - 添加邀请卡片组件和逻辑
**新增代码段:**
1. 邀请卡片 HTML 模板
2. 邀请卡片 CSS 样式
3. 定时器管理
4. 存储管理
5. 事件处理器
6. 自动发送消息函数
7. 热门话题区域
---
## 任务 1: 添加邀请卡片存储管理
**文件:**
- 修改: `static/widget.js`
在现有变量声明区域(第 153-160 行附近)添加存储管理相关变量和函数:
- [ ] **Step 1: 添加存储管理变量**
在 `widget.js` 中找到 `var wSessionId = null;` 这一行(约第 155 行),在其后添加:
```javascript
// Invite card state management
var inviteTimer = null;
var invitePanel = null;
var inviteShown = false;
var inviteStorageKey = "widget_invite_dismissed_" + window.location.pathname;
var isPermanentlyClosed = false;
```
- [ ] **Step 2: 添加存储检查函数**
在同一区域添加存储管理函数:
```javascript
function checkInviteStorage() {
var dismissed = sessionStorage.getItem(inviteStorageKey);
if (dismissed === "permanent") {
isPermanentlyClosed = true;
return true;
}
return false;
}
function setInviteDismissed(type) {
if (type === "permanent") {
sessionStorage.setItem(inviteStorageKey, "permanent");
isPermanentlyClosed = true;
} else {
sessionStorage.setItem(inviteStorageKey, "temporary");
}
}
function clearInviteDismissed() {
sessionStorage.removeItem(inviteStorageKey);
isPermanentlyClosed = false;
}
```
- [ ] **Step 3: Commit**
```bash
git add static/widget.js
git commit -m "feat: add invite card storage management functions"
```
---
## 任务 2: 添加邀请卡片 HTML 模板
**文件:**
- 修改: `static/widget.js`
- [ ] **Step 1: 添加邀请卡片 HTML 模板**
在 `widget.js` 中找到聊天面板的 HTML 定义(约第 115-134 行,`panel.innerHTML = [...]`),在其后、toggleBtn 定义之前添加:
```javascript
// Invite card HTML template
var inviteTemplate = [
'<div class="invite-panel">',
'<div class="invite-header">',
'<div class="invite-icon-wrapper">',
'<div class="invite-icon">🤖</div>',
'<span class="invite-status-dot"></span>',
'</div>',
'<div class="invite-header-content">',
'<div class="invite-title">Get stock list or quotation now</div>',
'<div class="invite-subtitle">Chat with us for available vehicles, delivery timing and distributor cooperation.</div>',
'</div>',
'<button class="invite-close-btn" aria-label="Close">',
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>',
'</button>',
'</div>',
'<div class="invite-body">',
'<div class="invite-topics">',
'<button class="invite-topic-btn" data-topic="Get Quotation">Get Quotation</button>',
'<button class="invite-topic-btn" data-topic="Distributor Cooperation">Distributor Cooperation</button>',
'</div>',
'<div class="invite-actions">',
'<button class="invite-start-btn">Start Chat',
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"></polyline></svg>',
'</button>',
'<button class="invite-dismiss-btn">No Thanks</button>',
'</div>',
'</div>',
'</div>'
].join("");
```
- [ ] **Step 2: 创建邀请卡片 DOM 元素**
在 toggleBtn 定义之前添加:
```javascript
var inviteEl = document.createElement("div");
inviteEl.className = "invite-container";
inviteEl.innerHTML = inviteTemplate;
inviteEl.style.display = "none"; // Hidden by default
```
- [ ] **Step 3: 将邀请卡片添加到容器**
找到 `container.appendChild(toggleBtn);` 这一行,在其之前添加:
```javascript
container.appendChild(inviteEl);
```
- [ ] **Step 4: 获取邀请卡片元素引用**
在现有元素引用定义区域(约第 161-165 行)添加:
```javascript
var inviteCloseBtn = shadow.querySelector(".invite-close-btn");
var inviteStartBtn = shadow.querySelector(".invite-start-btn");
var inviteDismissBtn = shadow.querySelector(".invite-dismiss-btn");
var inviteTopicBtns = shadow.querySelectorAll(".invite-topic-btn");
```
- [ ] **Step 5: Commit**
```bash
git add static/widget.js
git commit -m "feat: add invite card HTML template and DOM structure"
```
---
## 任务 3: 添加邀请卡片 CSS 样式
**文件:**
- 修改: `static/widget.js`
- [ ] **Step 1: 添加邀请卡片基础样式**
在 `style.textContent` 数组中(约第 38-106 行),在现有样式后、闭合标签前添加:
```javascript
// Invite card container
".invite-container { position:fixed; z-index:99998; }",
".invite-panel { width:360px; max-width:calc(100vw - 24px); overflow:hidden; border-radius:26px; border:1px solid #F1CCAE; background:white; box-shadow:0 28px 80px rgba(196,91,25,0.22); opacity:0; transform:translateY(20px); transition:opacity 0.3s, transform 0.3s; pointer-events:none; }",
".invite-panel.show { opacity:1; transform:translateY(0); pointer-events:auto; }",
".invite-panel.hide { display:none; }",
// Invite header
".invite-header { background:linear-gradient(135deg, #F08A34 0%, #EC8C2F 100%); padding:20px; color:white; display:flex; align-items:flex-start; gap:12px; }",
".invite-icon-wrapper { position:relative; flex-shrink:0; }",
".invite-icon { width:44px; height:44px; background:rgba(255,255,255,0.18); border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:20px; backdrop-filter:blur(10px); }",
".invite-status-dot { position:absolute; bottom:-2px; right:-2px; width:14px; height:14px; background:#22c55e; border-radius:50%; border:3px solid #F08A34; }",
".invite-header-content { flex:1; min-width:0; }",
".invite-title { font-size:16px; font-weight:600; line-height:1.4; }",
".invite-subtitle { font-size:13px; margin-top:4px; opacity:0.9; line-height:1.5; }",
".invite-close-btn { flex-shrink:0; width:32px; height:32px; border-radius:8px; background:rgba(255,255,255,0.1); border:none; color:white; cursor:pointer; display:flex; align-items:center; justify-content:center; transition:background 0.2s; }",
".invite-close-btn:hover { background:rgba(255,255,255,0.2); }",
// Invite body
".invite-body { padding:16px 20px; background:#FFF9F4; }",
// Topic buttons
".invite-topics { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:16px; }",
".invite-topic-btn { border-radius:22px; border:1px solid #EECFB6; background:white; padding:14px 16px; text-align:center; font-size:13px; font-weight:500; color:#8C4D1D; cursor:pointer; transition:background 0.2s; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }",
".invite-topic-btn:hover { background:#FFF7F1; }",
// Action buttons
".invite-actions { display:flex; gap:10px; }",
".invite-start-btn { flex:1; border-radius:20px; border:none; background:linear-gradient(135deg, #F08A34 0%, #EC8C2F 100%); padding:14px 20px; color:white; font-size:14px; font-weight:600; cursor:pointer; display:flex; align-items:center; justify-content:center; gap:8px; box-shadow:0 2px 8px rgba(240,138,49,0.25); transition:opacity 0.2s; }",
".invite-start-btn:hover { opacity:0.95; }",
".invite-dismiss-btn { border-radius:20px; border:1px solid #E8C4A9; background:white; padding:14px 20px; color:#A8591F; font-size:14px; font-weight:500; cursor:pointer; transition:background 0.2s; }",
".invite-dismiss-btn:hover { background:#FEFEFE; }",
// Responsive
"@media(max-width:450px){ .invite-panel{width:calc(100vw - 24px);} .invite-topics{grid-template-columns:1fr;} }",
```
确保在数组最后添加逗号(除了最后一项)。
- [ ] **Step 2: 调整现有聊天面板和悬浮按钮样式**
找到 `.chat-panel` 样式定义,修改其 `bottom` 定位:
```css
".chat-panel { position:absolute; bottom:80px; right:0; width:400px; height:520px; background:" + c.bg + "; border-radius:16px; box-shadow:0 10px 40px rgba(0,0,0,0.15); display:none; flex-direction:column; overflow:hidden; }",
```
修改 `bottom:70px` 为 `bottom:80px`,为邀请卡片留出空间。
- [ ] **Step 3: Commit**
```bash
git add static/widget.js
git commit -m "feat: add invite card CSS styles"
```
---
## 任务 4: 添加定时器逻辑
**文件:**
- 修改: `static/widget.js`
- [ ] **Step 1: 添加启动邀请定时器函数**
在存储管理函数后添加:
```javascript
function startInviteTimer() {
if (checkInviteStorage()) {
return; // Already dismissed, don't show
}
inviteTimer = window.setTimeout(function() {
if (!isOpen) {
showInvite();
}
}, 1800); // 1.8 seconds
}
function showInvite() {
if (inviteShown || isPermanentlyClosed) return;
inviteShown = true;
invitePanel = shadow.querySelector(".invite-panel");
if (invitePanel) {
invitePanel.classList.remove("hide");
invitePanel.classList.add("show");
}
}
function hideInvite(type) {
if (!invitePanel) return;
invitePanel.classList.remove("show");
invitePanel.classList.add("hide");
setInviteDismissed(type || "temporary");
}
```
- [ ] **Step 2: 在 init 函数末尾启动定时器**
找到 `init()` 函数的末尾(约第 265 行),在函数结束前添加:
```javascript
// Start invite timer
startInviteTimer();
```
- [ ] **Step 3: 添加定时器清理**
在 `toggle()` 函数中,当打开聊天面板时取消定时器:
找到 `toggle()` 函数定义(约第 169-173 行),修改为:
```javascript
function toggle() {
isOpen = !isOpen;
panel.classList.toggle("open", isOpen);
if (isOpen) {
if (inviteTimer) {
window.clearTimeout(inviteTimer);
inviteTimer = null;
}
hideInvite("temporary");
if (!wWs) wConnect();
} else {
// Only restart timer if not permanently closed
if (!isPermanentlyClosed && !checkInviteStorage()) {
inviteShown = false;
startInviteTimer();
}
}
}
```
- [ ] **Step 4: Commit**
```bash
git add static/widget.js
git commit -m "feat: add invite timer logic (1.8s delay)"
```
---
## 任务 5: 添加邀请卡片事件处理器
**文件:**
- 修改: `static/widget.js`
- [ ] **Step 1: 添加关闭按钮事件**
在现有事件监听器区域(约第 175-177 行之后)添加:
```javascript
// Invite close button
inviteCloseBtn.addEventListener("click", function() {
hideInvite("temporary");
});
```
- [ ] **Step 2: 添加 Start Chat 按钮事件**
```javascript
// Invite Start Chat button
inviteStartBtn.addEventListener("click", function() {
hideInvite("temporary");
if (!isOpen) {
toggle();
}
});
```
- [ ] **Step 3: 添加 No Thanks 按钮事件**
```javascript
// Invite No Thanks button
inviteDismissBtn.addEventListener("click", function() {
hideInvite("permanent");
});
```
- [ ] **Step 4: 添加话题按钮事件**
```javascript
// Invite topic buttons - auto send message
Array.prototype.forEach.call(inviteTopicBtns, function(btn) {
btn.addEventListener("click", function() {
var topic = this.getAttribute("data-topic");
hideInvite("temporary");
if (!isOpen) {
toggle();
}
// Auto send message after chat opens
setTimeout(function() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ content: topic }));
addMsg("user", topic);
inputEl.value = "";
showTyping();
}
}, 300);
});
});
```
- [ ] **Step 5: Commit**
```bash
git add static/widget.js
git commit -m "feat: add invite card event handlers"
```
---
## 任务 6: 添加聊天面板热门话题区域
**文件:**
- 修改: `static/widget.js`
- [ ] **Step 1: 修改消息区域 HTML**
找到 `panel.innerHTML` 定义中的消息区域部分(约第 124-127 行),修改为:
```javascript
'<div class="panel-messages" id="w-msgs">',
'<div class="help-block"><div style="display:flex;align-items:center;gap:14px;"><div class="help-content"><div class="help-title">How can we help?</div><div class="help-description">Ask about vehicles, market support, quotation, shipping and delivery.</div></div><a href="https://wa.me/+8619037179250" target="_blank" class="call-btn"><span style="font-size:15px;">📞</span><span>Call us</span></a></div></div>',
'<div class="popular-topics" id="w-popular-topics"><div class="popular-topics-label">POPULAR TOPICS</div><div class="popular-topics-grid"><button class="popular-topic-btn" data-topic="Get Quotation">Get Quotation</button><button class="popular-topic-btn" data-topic="Distributor Cooperation">Distributor Cooperation</button></div></div>',
'<div class="msg-wrapper"><div class="msg ai">Hey! What kind of car are you looking for?</div></div>',
'</div>',
```
- [ ] **Step 2: 添加热门话题 CSS 样式**
在 `style.textContent` 数组中添加:
```css
// Popular Topics
".popular-topics { margin-bottom:16px; }",
".popular-topics-label { font-size:11px; font-weight:600; color:#C16C2E; letter-spacing:0.14em; margin-bottom:8px; text-transform:uppercase; }",
".popular-topics-grid { display:flex; gap:8px; flex-wrap:wrap; }",
".popular-topic-btn { border-radius:20px; border:1px solid #F0D5BF; background:#FFF8F3; padding:8px 16px; font-size:12px; font-weight:500; color:#8C4D1D; cursor:pointer; transition:background 0.2s; white-space:nowrap; }",
".popular-topic-btn:hover { background:white; }",
```
- [ ] **Step 3: 添加热门话题事件处理**
在 `init()` 函数中添加热门话题按钮的事件监听:
```javascript
// Popular topics buttons
var popularTopicBtns = shadow.querySelectorAll(".popular-topic-btn");
Array.prototype.forEach.call(popularTopicBtns, function(btn) {
btn.addEventListener("click", function() {
var topic = this.getAttribute("data-topic");
// Hide popular topics after first interaction
var popularTopicsEl = shadow.getElementById("w-popular-topics");
if (popularTopicsEl) {
popularTopicsEl.style.display = "none";
}
// Send message
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ content: topic }));
addMsg("user", topic);
inputEl.value = "";
showTyping();
}
});
});
```
- [ ] **Step 4: 添加自动发送消息辅助函数**
在 `wSend()` 函数之前添加:
```javascript
function autoSendMessage(text) {
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
addMsg("user", text);
showTyping();
ws.send(JSON.stringify({ content: text }));
}
```
- [ ] **Step 5: 更新话题按钮使用新函数**
修改任务 5 中话题按钮的事件处理,使用新的辅助函数:
```javascript
// Invite topic buttons - auto send message
Array.prototype.forEach.call(inviteTopicBtns, function(btn) {
btn.addEventListener("click", function() {
var topic = this.getAttribute("data-topic");
hideInvite("temporary");
if (!isOpen) {
toggle();
}
// Auto send message after chat opens
setTimeout(function() {
autoSendMessage(topic);
}, 300);
});
});
```
- [ ] **Step 6: Commit**
```bash
git add static/widget.js
git commit -m "feat: add popular topics section to chat panel"
```
---
## 任务 7: 测试和验证
**文件:**
- 测试: `static/widget-test.html` 或在浏览器中测试
- [ ] **Step 1: 创建测试页面**
如果有 `widget-test.html`,打开它;否则创建一个简单的测试页面:
```html
<!DOCTYPE html>
<html>
<head>
<title>Widget Invite Test</title>
</head>
<body>
<h1>Widget Invite Card Test</h1>
<p>Invite should appear after 1.8 seconds</p>
<script src="/static/widget.js" data-position="bottom-right" data-theme="light"></script>
</body>
</html>
```
- [ ] **Step 2: 测试基本功能**
打开测试页面,验证:
1. ✅ 页面加载 1.8 秒后邀请卡片自动弹出
2. ✅ 邀请卡片样式正确(橙色头部、两个话题按钮)
3. ✅ 点击 "Get Quotation" → 邀请关闭 → 聊天打开 → 消息自动发送
4. ✅ 点击 "Distributor Cooperation" → 邀请关闭 → 聊天打开 → 消息自动发送
5. ✅ 点击 "Start Chat" → 邀请关闭 → 聊天打开
6. ✅ 点击 "No Thanks" → 邀请永久关闭
7. ✅ 点击悬浮按钮(邀请可见时)→ 邀请关闭 → 聊天打开
8. ✅ 点击悬浮按钮(邀请隐藏时)→ 聊天打开
- [ ] **Step 3: 测试页面独立性**
在多个标签页/页面中测试:
1. ✅ 页面 A 关闭邀请后,页面 B 仍能弹出邀请
2. ✅ 页面 A 刷新后,已关闭状态保持
3. ✅ 关闭整个网站重新打开,所有状态重置
- [ ] **Step 4: 测试热门话题**
1. ✅ 聊天面板显示 "POPULAR TOPICS" 区域
2. ✅ 点击话题按钮 → 消息自动发送
3. ✅ 发送第一条消息后,热门话题隐藏
- [ ] **Step 5: 测试响应式**
在不同屏幕尺寸下测试:
1. ✅ 移动端(<450px)样式正确
2. ✅ 话题按钮在小屏幕上正确排列
- [ ] **Step 6: 测试边界情况**
1. ✅ 快速连续点击悬浮按钮
2. ✅ 在邀请弹出前打开聊天
3. ✅ 在消息发送过程中关闭邀请
- [ ] **Step 7: Commit**
```bash
# 如果创建了测试文件
git add static/widget-test.html
git commit -m "test: add widget invite card test page"
```
---
## 任务 8: 最终清理和文档
- [ ] **Step 1: 代码审查**
检查代码:
1. ✅ 无 console.log 调试语句
2. ✅ 变量命名一致
3. ✅ 注释清晰
4. ✅ 无重复代码
- [ ] **Step 2: 浏览器兼容性测试**
在不同浏览器中测试:
1. ✅ Chrome/Edge (Chromium)
2. ✅ Firefox
3. ✅ Safari (如果可用)
- [ ] **Step 3: 性能检查**
1. ✅ 无内存泄漏(定时器清理)
2. ✅ 无不必要的 DOM 操作
3. ✅ 存储操作高效
- [ ] **Step 4: 最终 Commit**
```bash
git add static/widget.js
git commit -m "chore: final cleanup and polish invite card feature"
```
---
## 验收标准
### 功能要求
- ✅ 页面加载 1.8 秒后自动弹出邀请卡片
- ✅ 包含两个话题按钮:Get Quotation、Distributor Cooperation
- ✅ 点击话题按钮 → 关闭邀请 → 打开聊天 → 自动发送消息
- ✅ 点击 "Start Chat" → 关闭邀请 → 打开聊天
- ✅ 点击 "No Thanks" → 永久关闭邀请
- ✅ 每个页面独立管理状态
- ✅ 聊天面板显示热门话题区域
### 样式要求
- ✅ 橙色渐变头部:`linear-gradient(135deg, #F08A34 0%, #EC8C2F 100%)`
- ✅ 圆角 26px
- ✅ 阴影效果正确
- ✅ 淡入 + 滑入动画
- ✅ 响应式设计
### 技术要求
- ✅ 使用 sessionStorage 存储状态
- ✅ 使用 Shadow DOM 隔离样式
- ✅ 定时器正确清理
- ✅ 无内存泄漏
---
**预计工作量**: 约 2-3 小时
**文件修改**: 1 个文件(static/widget.js)
**新增代码**: 约 250-300 行
@@ -0,0 +1,292 @@
# WhatsApp / Email 动态配置 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将写死的 WhatsApp / Email 联系方式改为通过 `<script>` 标签的 `data-whatsapp` / `data-email` 属性动态配置,未提供时回退现有默认值,保持 100% 向后兼容。
**Architecture:** 复用 `widget.js` 既有的 `script.getAttribute("data-*") || "默认值"` 配置范式,新增两个属性并替换 3 处写死的 `href`。独立页 `chat.html`(加载 `chat.js`,**不**加载 `widget.js`)采用**同一套机制**:`chat.js` 读取自身 `<script>` 标签的 `data-whatsapp` / `data-email` 并应用到两个锚点。
**Tech Stack:** 原生 JavaScript(ES5 风格 IIFE)、静态 HTML。无构建步骤、无打包器。
**Spec:** [docs/superpowers/specs/2026-06-18-dynamic-contact-config-design.md](../specs/2026-06-18-dynamic-contact-config-design.md)
---
## ⚠️ 关于测试方式(重要)
本仓库为**纯静态 JS 前端 + Python 后端**,**没有任何 JS 测试框架**(无 `package.json`,`tests/` 只有空的 `__init__.py`)。为本改动引入 JS 测试框架违反 YAGNI。
因此本计划**不使用自动化 TDD**,而是采用**手动浏览器验证**:每个 Task 末尾用浏览器打开页面、通过 DevTools 检查 `href` 实际值。验证标准写明「期望看到的值」,与自动化测试的断言等价。
**验证通用方法:**
- 方式 A(推荐,贴近真实部署):启动后端服务,浏览器访问页面,F12 打开 DevTools。
- 方式 B(最快,免启动服务):直接用浏览器打开本地 HTML 文件(`file://`)。组件的 WebSocket 会连不上,但**不影响 `href` 值的验证**(`href` 在初始化时就写好,与 WS 无关)。
- 检查 Shadow DOM 内的链接:DevTools → Elements → 展开组件 Shadow Root,或在 Console 执行:
```js
// widget(shadow DOM 内)
document.querySelector('#cc-widget-host').shadowRoot.querySelectorAll('a.whatsapp-btn, a.email-btn').forEach(a => console.log(a.className, a.href));
```
---
## 文件结构(File Structure)
| 文件 | 职责 | 本次改动 |
|------|------|----------|
| `static/widget.js` | 嵌入式组件(第三方站点通过 `<script>` 引入,渲染 Shadow DOM 聊天窗) | 读取 2 个新属性 + 替换 3 处 `href` |
| `static/js/chat.js` | 独立整页 `chat.html` 的交互逻辑(WebSocket、消息渲染) | 新增读取自身 script 标签配置 + 设置 2 个锚点 `href` |
| `static/chat.html` | 独立整页聊天页(加载 `chat.js`) | 给 `chat.js` 的 `<script>` 标签加 2 个 data 属性 |
| `static/widget-test.html` | 嵌入式组件的本地测试/演示页 | 给组件 `<script>` 标签加 2 个 data 属性(演示用) |
边界说明:`widget.js` 与 `chat.js` 是两套独立运行时(前者在 Shadow DOM,后者操作 light DOM),但**共用同一份配置契约**(相同的属性名、相同的默认值、相同的「缺失则回退」语义)。两个文件各自独立持有默认值常量——它们是部署级常量,没有运行时共享的必要(也无打包器可共享),重复这两个字符串是可接受的、符合现有代码风格。
---
## Task 1: `widget.js` 读取配置并接入 3 处 href
**Files:**
- Modify: `static/widget.js:8-12`(新增配置读取)
- Modify: `static/widget.js:170`(面板 help-block,单行内含 WhatsApp + Email 两个 `href`)
- Modify: `static/widget.js:199`(邀请卡 WhatsApp)
- Modify: `static/widget.js:203`(邀请卡 Email)
- [ ] **Step 1: 新增配置读取**
在 `static/widget.js` 第 8-12 行现有 `getAttribute` 块的末尾(第 11 行 `var autoOpen = ...` 之后、第 12 行 `var wsBase = ...` 之前)插入两行:
```js
var whatsapp = script.getAttribute("data-whatsapp") || "https://wa.me/+8619037179250";
var email = script.getAttribute("data-email") || "mailto:info@borderlesscar.com";
```
插入后该区域应为:
```js
var position = script.getAttribute("data-position") || "bottom-right";
var theme = script.getAttribute("data-theme") || "light";
var title = script.getAttribute("data-title") || "HESU Support";
var autoOpen = script.getAttribute("data-auto-open") === "true";
var whatsapp = script.getAttribute("data-whatsapp") || "https://wa.me/+8619037179250";
var email = script.getAttribute("data-email") || "mailto:info@borderlesscar.com";
var wsBase = new URL(script.src).origin;
```
- [ ] **Step 2: 替换面板 help-block 内的两处 href(第 170 行)**
第 170 行是**单个字符串数组元素**,里面同时含 WhatsApp 和 Email 两个 `<a>`。在该行内做两处子串替换(不要整行重写,避免破坏 SVG):
| 原子串 | 替换为 |
|--------|--------|
| `href="https://wa.me/+8619037179250" target="_blank" class="call-btn whatsapp-btn"` | `href="' + whatsapp + '" target="_blank" class="call-btn whatsapp-btn"` |
| `href="mailto:info@borderlesscar.com" class="call-btn email-btn"` | `href="' + email + '" class="call-btn email-btn"` |
> 原理:该数组元素用单引号 `'...'` 包裹,内部 HTML 属性用双引号。`href="' + whatsapp + '"` 即关闭单引号、拼接变量、重开单引号,与现有 `title` 变量在第 163 行的拼接方式一致。
- [ ] **Step 3: 替换邀请卡 WhatsApp href(第 199 行)**
第 199 行:
```js
'<a href="https://wa.me/+8619037179250" target="_blank" class="invite-action-btn whatsapp-btn">',
```
替换为:
```js
'<a href="' + whatsapp + '" target="_blank" class="invite-action-btn whatsapp-btn">',
```
- [ ] **Step 4: 替换邀请卡 Email href(第 203 行)**
第 203 行:
```js
'<a href="mailto:info@borderlesscar.com" class="invite-action-btn email-btn">',
```
替换为:
```js
'<a href="' + email + '" class="invite-action-btn email-btn">',
```
- [ ] **Step 5: 全量自检——确认无残留写死值**
Run: `grep -n "wa.me/+8619037179250\|mailto:info@borderlesscar.com" static/widget.js`
Expected: **无任何输出**(3 处已全部替换为变量拼接)。
- [ ] **Step 6: 手动验证——默认值(向后兼容)**
此时 `widget-test.html` 尚未加新属性,等于「未提供」。
打开 `static/widget-test.html`(方式 A 或 B),DevTools Console 执行:
```js
[...document.querySelector('#cc-widget-host').shadowRoot.querySelectorAll('a')].filter(a => a.className.includes('whatsapp-btn') || a.className.includes('email-btn')).forEach(a => console.log(a.className, '->', a.href));
```
Expected(应输出 4 条,邀请卡 + 面板各 2 个):
- `whatsapp-btn` → `https://wa.me/+8619037179250`
- `email-btn` → `mailto:info@borderlesscar.com`
与改动前完全一致 → **向后兼容验证通过**。
- [ ] **Step 7: 手动验证——自定义值覆盖(临时)**
临时编辑 `static/widget-test.html:69`,给组件 script 标签加上自定义值:
```html
<script src="/static/widget.js" data-position="bottom-right" data-theme="light" data-whatsapp="https://wa.me/8613800138000" data-email="mailto:demo@hesu-auto.com"></script>
```
刷新页面,再次执行 Step 6 的 Console 命令。
Expected:
- `whatsapp-btn` → `https://wa.me/8613800138000`
- `email-btn` → `mailto:demo@hesu-auto.com`
覆盖路径验证通过后,**先不要回退**——这个改动正好是 Task 3 的成果,直接进入提交。
> 注:即使这里手滑回退了也无妨——Task 3 Step 1 会重新把它加回来。本 Task 的提交只包含 `widget.js`,不含 `widget-test.html`。
- [ ] **Step 8: 提交**
```bash
git add static/widget.js
git commit -m "$(cat <<'EOF'
feat(widget): WhatsApp/Email 改为 data-whatsapp/data-email 动态配置
组件读取自身 <script> 标签的 data-whatsapp / data-email 属性,
未提供时回退现有默认值,保持向后兼容。替换面板 help-block 与邀请卡
共 3 处 href。
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## Task 2: `chat.js` + `chat.html` 独立页动态配置
**Files:**
- Modify: `static/js/chat.js:1-2`(IIFE 顶部插入配置读取与应用)
- Modify: `static/chat.html:80`(给 chat.js script 标签加 data 属性)
- [ ] **Step 1: `chat.js` 顶部新增配置读取与应用**
在 `static/js/chat.js` 第 1 行 `(function () {` 之后、第 2 行 `var sessionId = ...` 之前,插入:
```js
// 联系方式配置(与 widget.js 的 data-* 机制一致)
var chatScript = document.currentScript || document.querySelector('script[src*="chat.js"]');
var whatsapp = (chatScript && chatScript.getAttribute("data-whatsapp")) || "https://wa.me/+8619037179250";
var email = (chatScript && chatScript.getAttribute("data-email")) || "mailto:info@borderlesscar.com";
var waBtn = document.querySelector('.whatsapp-btn');
var emBtn = document.querySelector('.email-btn');
if (waBtn) waBtn.href = whatsapp;
if (emBtn) emBtn.href = email;
```
> 时序安全:`chat.js` 在 `<body>` 末尾加载(`chat.html:80`,晚于聊天容器 DOM `chat.html:10-78`),故同步 `querySelector` 时两个锚点已存在。`document.currentScript` 对普通(非 async/defer)`<script src>` 在 IIFE 顶部非空;`querySelector` 回退为防御性写法。
- [ ] **Step 2: `chat.html` 给 chat.js script 标签加 data 属性**
`static/chat.html:80` 当前:
```html
<script src="/static/js/chat.js"></script>
```
替换为(用与 Task 1 Step 7 一致的自定义演示值):
```html
<script src="/static/js/chat.js" data-whatsapp="https://wa.me/8613800138000" data-email="mailto:demo@hesu-auto.com"></script>
```
> `chat.html:35` 与 `:39` 原有的静态 `href` **保留不动**——作为 `chat.js` 未执行时的 fallback(默认值),`chat.js` 启动后会覆盖为配置值。
- [ ] **Step 3: 手动验证——自定义值生效**
打开 `static/chat.html`(方式 A 或 B),DevTools Console 执行:
```js
[...document.querySelectorAll('a.whatsapp-btn, a.email-btn')].forEach(a => console.log(a.className, '->', a.href));
```
Expected:
- `call-btn whatsapp-btn` → `https://wa.me/8613800138000`
- `call-btn email-btn` → `mailto:demo@hesu-auto.com`
(chat.html 是 light DOM,不需要 shadowRoot。)
- [ ] **Step 4: 手动验证——回退默认值(临时)**
临时把 `chat.html:80` 的两个 data 属性删掉(恢复成 `<script src="/static/js/chat.js"></script>`),刷新页面,再次执行 Step 3 的 Console 命令。
Expected:
- `whatsapp-btn` → `https://wa.me/+8619037179250`
- `email-btn` → `mailto:info@borderlesscar.com`
回退验证通过后,**把 data 属性加回去**(恢复 Step 2 的状态)。
- [ ] **Step 5: 提交**
```bash
git add static/js/chat.js static/chat.html
git commit -m "$(cat <<'EOF'
feat(chat): 独立页 WhatsApp/Email 改为 data-* 动态配置
chat.js 读取自身 <script> 标签的 data-whatsapp / data-email(与
widget.js 同一套机制),应用到页面两个锚点;chat.html script 标签
加上演示属性,原有静态 href 保留为 fallback 默认值。
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: `widget-test.html` 演示属性 + 更新测试清单
**Files:**
- Modify: `static/widget-test.html:69`(确认演示属性,Task 1 Step 7 已临时加,此处定稿)
- Modify: `docs/test-checklist.md`(补充手动验证条目)
- [ ] **Step 1: 确认 `widget-test.html` 演示属性到位**
确认 `static/widget-test.html:69` 已是(Task 1 Step 7 改成的):
```html
<script src="/static/widget.js" data-position="bottom-right" data-theme="light" data-whatsapp="https://wa.me/8613800138000" data-email="mailto:demo@hesu-auto.com"></script>
```
若 Step 7 之后被改动过,恢复成上面这样。
- [ ] **Step 2: 全局终检——确认无残留写死联系方式(前端)**
Run: `grep -rn "wa.me/+8619037179250\|mailto:info@borderlesscar.com" static/widget.js static/js/chat.js`
Expected: **无输出**(两处运行时文件都已参数化)。
注:`static/chat.html:35` 和 `:39` 仍含默认值字符串,这是**有意保留的 fallback**,属正常。
- [ ] **Step 3: 更新测试清单文档**
在 `docs/test-checklist.md` 末尾追加一节(若文件不存在则新建并加标题 `# 测试清单`):
```markdown
## WhatsApp / Email 动态配置(2026-06-18)
- [ ] **默认值(未传属性)**:组件(widget-test.html 去掉 data 属性时)与独立页(chat.html 去掉 data 属性时)的 WhatsApp / Email 按钮分别指向 `https://wa.me/+8619037179250` 与 `mailto:info@borderlesscar.com`,与历史行为一致。
- [ ] **自定义值**:widget-test.html 传 `data-whatsapp` / `data-email` 后,组件内三处链接(面板 help-block 的 WhatsApp、面板 help-block 的 Email、邀请卡 WhatsApp、邀请卡 Email)均指向自定义地址。
- [ ] **独立页自定义值**:chat.html 传 `data-whatsapp` / `data-email` 后,两个按钮指向自定义地址。
- [ ] **空字符串**:`data-whatsapp=""` 等价于未传,回退默认值。
- [ ] **JS 未执行**:chat.html 在 JS 禁用 / 未执行时,静态 `href` 仍为默认值,不出现空链接。
```
- [ ] **Step 4: 提交**
```bash
git add static/widget-test.html docs/test-checklist.md
git commit -m "$(cat <<'EOF'
docs(test): widget-test 演示动态配置属性 + 补充测试清单
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## 完成标准(Definition of Done)
- [ ] `static/widget.js` 与 `static/js/chat.js` 中不再有任何写死的 `wa.me/+8619037179250` / `mailto:info@borderlesscar.com`(grep 无输出)。
- [ ] 不传任何新属性时,所有联系方式链接与改动前完全一致(向后兼容)。
- [ ] 传入自定义 `data-whatsapp` / `data-email` 时,组件 3 处 + 独立页 2 处链接均指向自定义值。
- [ ] 3 个 commit 已提交,每个 commit 自包含、可独立验证。
- [ ] `docs/test-checklist.md` 已补充对应手动验证条目。
## 风险与回滚
- 风险极低:纯前端字符串替换 + 新增属性读取,不触碰 WebSocket、后端、样式。
- 回滚:`git revert` 对应 commit 即可;未传属性的部署本来就无任何行为变化。
@@ -0,0 +1,365 @@
# 邀请卡自动弹出开关 + 移动端悬浮按钮 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为嵌入式组件 `widget.js` 新增两个 `data-*` 配置开关——`data-auto-invite`(控制邀请卡是否 1.8s 自动弹出)与 `data-mobile`(切换到移动端竖排悬浮按钮 D2),默认行为 100% 向后兼容。
**Architecture:** 复用 `widget.js` 既有的 `script.getAttribute("data-*")` 配置范式。`data-auto-invite` 在 `startInviteTimer()` 开头加一句短路;`data-mobile` 走「独立移动端 DOM 模板」分支(桌面模板逐字不动),并配套一份贴角的位置表 `positionStylesMobile` 与移动端 CSS。
**Tech Stack:** 原生 JavaScript(ES5 风格 IIFE + Shadow DOM)、静态 HTML。无构建步骤、无打包器。
**Spec:** [docs/superpowers/specs/2026-06-23-auto-invite-toggle-and-mobile-button-design.md](../specs/2026-06-23-auto-invite-toggle-and-mobile-button-design.md)
---
## ⚠️ 关于测试方式(重要)
本仓库为**纯静态 JS 前端**,**没有任何 JS 测试框架**(无 `package.json`,`tests/` 只有空的 `__init__.py`)。为本改动引入 JS 测试框架违反 YAGNI。
因此本计划**不使用自动化 TDD**,而是采用**手动浏览器验证**:每个 Task 末尾用浏览器打开页面、通过 DevTools 检查实际 DOM / 行为。验证标准写明「期望看到的结果」,与自动化断言等价。
**验证通用方法:**
- 方式 A(推荐,贴近真实部署):启动后端服务(`bash deploy.sh` 或 `docker compose up`),浏览器访问页面。
- 方式 B(最快,免启动服务):直接用浏览器打开本地 HTML 文件(`file://`)。组件的 WebSocket 会连不上,但**不影响本特性的验证**——邀请卡弹出与按钮渲染都在初始化时完成,与 WS 无关。
- 检查 Shadow DOM 内部:DevTools → Elements → 展开组件 Shadow Root,或在 Console 执行下文给出的命令。
---
## 文件结构(File Structure)
| 文件 | 职责 | 本次改动 |
|------|------|----------|
| `static/widget.js` | 嵌入式组件(第三方站点通过 `<script>` 引入,渲染 Shadow DOM 聊天窗) | 读取 2 个新属性 + 邀请卡短路 1 行 + 悬浮按钮分两套模板 + 新增移动端 CSS + 新增 `positionStylesMobile` |
| `static/widget-test.html` | 嵌入式组件的本地测试/演示页 | 给组件 `<script>` 标签加 `data-mobile="true"`(演示 D2 按钮) |
| `docs/test-checklist.md` | 手动测试清单 | 补充本次两个开关的验证条目 |
边界说明:本次只改 `widget.js`(嵌入式组件)。独立聊天页 `chat.html` / `chat.js` **不在范围内**——独立页没有邀请卡逻辑,悬浮按钮也是 widget 独有概念(见 spec §1.3)。
---
## Task 1: `data-auto-invite` —— 邀请卡自动弹出开关
**Files:**
- Modify: `static/widget.js`(IIFE 顶部新增 `autoInvite` 读取)
- Modify: `static/widget.js`(`startInviteTimer()` 开头新增短路)
- [ ] **Step 1: 新增 `autoInvite` 配置读取**
在 `static/widget.js` 第 13 行 `var email = ...` 之后、第 14 行 `var wsBase = new URL(script.src).origin;` 之前,插入一行:
```js
var autoInvite = script.getAttribute("data-auto-invite") !== "false";
```
插入后该区域应为:
```js
var whatsapp = script.getAttribute("data-whatsapp") || "https://wa.me/+8619037179250";
var email = script.getAttribute("data-email") || "mailto:info@borderlesscar.com";
var autoInvite = script.getAttribute("data-auto-invite") !== "false";
var wsBase = new URL(script.src).origin;
```
> 语义:属性缺失、空串或任何非 `"false"` 值 → `autoInvite = true`(保持现状自动弹);仅 `data-auto-invite="false"` → `false`。
- [ ] **Step 2: `startInviteTimer()` 开头加短路**
定位 `startInviteTimer` 函数(约第 306 行),当前为:
```js
function startInviteTimer() {
if (checkInviteStorage()) {
return; // Already dismissed, don't show
}
```
改为(在函数体最开头插入一行 `if (!autoInvite) return;`):
```js
function startInviteTimer() {
if (!autoInvite) return; // 配置关闭则不启动计时器,邀请卡永不自动弹
if (checkInviteStorage()) {
return; // Already dismissed, don't show
}
```
> `startInviteTimer` 定义在 `init()` 内部(嵌套函数),`autoInvite` 在 IIFE 顶层(Step 1),闭包可访问,无需传参。
- [ ] **Step 3: 手动验证——默认值(向后兼容,邀请卡仍弹)**
`widget-test.html` 此刻未加 `data-auto-invite`,等于「未提供」→ `autoInvite=true`。
打开 `static/widget-test.html`(方式 A 或 B),等待约 2 秒。
Expected:**邀请卡在 ~1.8s 后自动弹出**(橙色渐变 header、🤖 图标、两个 topic 按钮),与改动前一致。
- [ ] **Step 4: 手动验证——`data-auto-invite="false"` 不弹(临时)**
临时编辑 `static/widget-test.html:69` 的组件 script 标签,加上 `data-auto-invite="false"`:
```html
<script src="/static/widget.js" data-position="bottom-right" data-theme="light" data-whatsapp="https://wa.me/8613800138000" data-email="mailto:demo@hesu-auto.com" data-auto-invite="false"></script>
```
刷新页面,等待 5 秒以上。
Expected:**邀请卡不再自动弹出**;悬浮按钮照常显示,点击仍能打开聊天面板。
验证通过后,**把 `data-auto-invite="false"` 从该标签删掉**(恢复成 Step 3 的状态,保持测试页的邀请卡回归用途)。Task 3 不会重新加它。
- [ ] **Step 5: 提交**
```bash
git add static/widget.js
git commit -m "$(cat <<'EOF'
feat(widget): 新增 data-auto-invite 控制邀请卡自动弹出
默认(true)保持 1.8s 自动弹出;设 data-auto-invite="false" 时
startInviteTimer 直接返回,邀请卡不再自动弹,悬浮按钮照常可用。
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## Task 2: `data-mobile` —— 移动端竖排悬浮按钮(D2)+ 贴角位置
**Files:**
- Modify: `static/widget.js`(IIFE 顶部新增 `isMobile` 读取 + `positionStylesMobile` + `pos` 选择)
- Modify: `static/widget.js`(样式数组新增移动端 CSS)
- Modify: `static/widget.js`(`toggleBtn` 改为按 `isMobile` 分两套模板)
- [ ] **Step 1: 新增 `isMobile` 配置读取**
在 Task 1 新增的 `var autoInvite = ...` 之后、`var wsBase = ...` 之前,插入:
```js
var isMobile = script.getAttribute("data-mobile") === "true";
```
插入后该区域应为:
```js
var autoInvite = script.getAttribute("data-auto-invite") !== "false";
var isMobile = script.getAttribute("data-mobile") === "true";
var wsBase = new URL(script.src).origin;
```
- [ ] **Step 2: 新增 `positionStylesMobile` 并切换 `pos`**
定位 `positionStyles` 定义(约第 16-21 行):
```js
var positionStyles = {
"bottom-right": { bottom: "80px", right: "10px" },
"bottom-left": { bottom: "20px", left: "20px" },
"top-right": { top: "20px", right: "20px" },
"top-left": { top: "20px", left: "20px" },
};
```
在其**下方**紧接着新增一份移动端位置表:
```js
var positionStylesMobile = {
"bottom-right": { bottom: "20px", right: "16px" },
"bottom-left": { bottom: "20px", left: "16px" },
"top-right": { top: "20px", right: "16px" },
"top-left": { top: "20px", left: "16px" },
};
```
然后定位 `pos` 计算(约第 29 行,紧接 `var c = colors[theme] || colors.light;` 之后):
```js
var pos = positionStyles[position] || positionStyles["bottom-right"];
```
改为按 `isMobile` 选择表:
```js
var pos = (isMobile ? positionStylesMobile : positionStyles)[position]
|| (isMobile ? positionStylesMobile : positionStyles)["bottom-right"];
```
> 目的:移动端圆钮仅 56px,沿用桌面 `bottom-right` 的 `bottom:80px` 会离底边偏高;移动端改用贴角的 ~20px。桌面端 `positionStyles` 逐字不动。
- [ ] **Step 3: 样式数组新增移动端 CSS**
定位样式数组中 `.toggle-btn:hover` 那一行(约第 47 行):
```js
".toggle-btn:hover { transform:scale(1.1); }",
```
在其**下方**紧接着插入 5 行移动端按钮样式:
```js
".toggle-btn.mobile { flex-direction:column; gap:5px; padding:0; background:transparent; box-shadow:none; border-radius:0; }",
".toggle-btn.mobile:hover { transform:scale(1.05); }",
".toggle-btn.mobile .mobile-circle { position:relative; width:56px; height:56px; border-radius:50%; background:#f08a31; display:flex; align-items:center; justify-content:center; font-size:28px; box-shadow:0 4px 16px rgba(240,138,49,0.45); }",
".toggle-btn.mobile .mobile-dot { position:absolute; bottom:2px; right:2px; width:13px; height:13px; background:#22c55e; border-radius:50%; border:2px solid #f08a31; }",
".toggle-btn.mobile .mobile-label { color:#8C4D1D; font-size:10px; font-weight:600; line-height:1.1; }",
```
- [ ] **Step 4: `toggleBtn` 改为按 `isMobile` 分两套模板**
定位 `toggleBtn` 创建处(约第 233-235 行):
```js
var toggleBtn = document.createElement("button");
toggleBtn.className = "toggle-btn";
toggleBtn.innerHTML = `
```
**改动一**:把 className 改为带 mobile 修饰:
```js
toggleBtn.className = "toggle-btn" + (isMobile ? " mobile" : "");
```
**改动二**:把紧跟其后的单行 `toggleBtn.innerHTML = \`...桌面模板...\`;` 改成 `if/else` 分支——在它**前面**插入移动端分支、把它本身包进 `else`。
具体:在 `toggleBtn.className = ...` 这一行之后、原 `toggleBtn.innerHTML = \`` 这一行之前,插入:
```js
if (isMobile) {
toggleBtn.innerHTML = `
<div class="mobile-circle">🤖<span class="mobile-dot"></span></div>
<span class="mobile-label">Chat with us</span>
`;
} else {
```
然后在原桌面模板赋值的结束反引号 `` `; `` **之后**,补上闭合的 `}`:
```js
}
```
改动后该区域结构应为(桌面模板内容逐字不变,仅被包进 `else { ... }`):
```js
var toggleBtn = document.createElement("button");
toggleBtn.className = "toggle-btn" + (isMobile ? " mobile" : "");
if (isMobile) {
toggleBtn.innerHTML = `
<div class="mobile-circle">🤖<span class="mobile-dot"></span></div>
<span class="mobile-label">Chat with us</span>
`;
} else {
toggleBtn.innerHTML = `
<div style="position:relative;">
... (现有桌面模板,原样不动) ...
</div>
`;
}
```
> `toggleBtn` 仍是接收点击的元素,[widget.js:367](../../../static/widget.js#L367) 的 `toggleBtn.addEventListener("click", toggle)` 无需改动,点击圆钮或文字均触发 `toggle()`。
- [ ] **Step 5: 手动验证——默认值(向后兼容,桌面款按钮不变)**
`widget-test.html` 此刻未加 `data-mobile` → `isMobile=false` → 走桌面分支。
打开 `static/widget-test.html`(方式 A 或 B),DevTools Console 执行:
```js
const host = document.querySelector('#cc-widget-host');
const btn = host.shadowRoot.querySelector('.toggle-btn');
console.log('class:', btn.className);
console.log('html:', btn.innerHTML.slice(0, 60));
```
Expected:
- `class:` → `toggle-btn`(**不含** `mobile`)
- `html:` → 以桌面模板开头(含 `position:relative` 的 div)
视觉上:右下角是**现有的宽药丸按钮**(🤖 图标 + "Chat with us" / "Usually replies quickly" 两行文字),与改动前一致 → **向后兼容验证通过**。
- [ ] **Step 6: 手动验证——`data-mobile="true"` 切到 D2(临时)**
临时编辑 `static/widget-test.html:69`,给组件 script 标签加上 `data-mobile="true"`(**保留** Step 1 Task 1 回退后去掉 `data-auto-invite` 的状态,即当前标签不含 auto-invite):
```html
<script src="/static/widget.js" data-position="bottom-right" data-theme="light" data-whatsapp="https://wa.me/8613800138000" data-email="mailto:demo@hesu-auto.com" data-mobile="true"></script>
```
刷新页面,DevTools Console 执行:
```js
const host = document.querySelector('#cc-widget-host');
const btn = host.shadowRoot.querySelector('.toggle-btn');
const container = host.shadowRoot.querySelector('.widget-container');
console.log('class:', btn.className);
console.log('circle:', !!btn.querySelector('.mobile-circle'), 'label:', btn.querySelector('.mobile-label')?.textContent);
console.log('position bottom/right:', getComputedStyle(container).bottom, getComputedStyle(container).right);
```
Expected:
- `class:` → `toggle-btn mobile`
- `circle:` → `true`,`label:` → `Chat with us`
- `position bottom/right:` → `20px` / `16px`(贴角,**非** 桌面的 `80px`)
视觉上:右下角是 **D2 竖排按钮**——56px 橙色圆钮 🤖 在上、"Chat with us" 文字外置在下(非橙色底),且**更贴近屏幕右下角**。点击圆钮或文字均能开关聊天面板。
验证通过后,**保留** `data-mobile="true"` 不要回退——这正是 Task 3 要定稿的演示状态。
- [ ] **Step 7: 手动验证——`data-position` 其它取值不报错(临时)**
把 Step 6 标签里的 `data-position` 分别临时改成 `bottom-left`、`top-right`、`top-left`,各刷新一次。
Expected:D2 按钮出现在对应角,位置贴角(~20px),无 JS 报错(DevTools Console 无红色)。验证完**恢复** `data-position="bottom-right"`。
- [ ] **Step 8: 提交**
```bash
git add static/widget.js
git commit -m "$(cat <<'EOF'
feat(widget): 新增 data-mobile 切换移动端竖排悬浮按钮(D2)
data-mobile="true" 时悬浮按钮改为竖排样式(56px 圆钮在上、"Chat with us"
文字外置在下),并切换到贴角位置表 positionStylesMobile。桌面款模板与
位置逻辑逐字不动,默认行为完全向后兼容。
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: `widget-test.html` 演示属性 + 更新测试清单
**Files:**
- Modify: `static/widget-test.html:69`(确认 `data-mobile="true"` 演示属性到位,Task 2 Step 6 已加)
- Modify: `docs/test-checklist.md`(补充手动验证条目)
- [ ] **Step 1: 确认 `widget-test.html` 演示属性到位**
确认 `static/widget-test.html:69` 已是(Task 2 Step 6 改成的):
```html
<script src="/static/widget.js" data-position="bottom-right" data-theme="light" data-whatsapp="https://wa.me/8613800138000" data-email="mailto:demo@hesu-auto.com" data-mobile="true"></script>
```
若之后被改动过,恢复成上面这样。
> 故意**不加** `data-auto-invite="false"`:widget-test.html 的核心用途就是测试邀请卡自动弹出(页面标题即 "Widget Invite Card Test Page"),加 false 会让邀请卡不弹、破坏其回归用途。`data-auto-invite` 的关闭路径靠临时编辑验证(见测试清单)。
- [ ] **Step 2: 更新测试清单文档**
在 `docs/test-checklist.md` 末尾追加一节:
```markdown
## 邀请卡自动弹出开关 + 移动端悬浮按钮(2026-06-23)
### data-auto-invite
- [ ] **默认(未传)**:组件邀请卡在 ~1.8s 后自动弹出,与历史一致。
- [ ] **`data-auto-invite="false"`**:邀请卡不再自动弹出;悬浮按钮照常显示,点击仍能打开聊天面板。
- [ ] **空值/非法值**:`data-auto-invite=""` 或 `data-auto-invite="0"` 等价于未传,仍自动弹。
- [ ] **与 data-mobile 独立**:`data-mobile="true" data-auto-invite="false"` 时竖排按钮 + 不弹邀请卡,互不影响。
### data-mobile
- [ ] **默认(未传)**:悬浮按钮为桌面款宽药丸(`toggle-btn`,不含 `mobile`),与历史一致。
- [ ] **`data-mobile="true"`**:悬浮按钮为 D2 竖排(`toggle-btn mobile`,含 `.mobile-circle` + `.mobile-label`="Chat with us"),点击圆钮或文字均能开关聊天面板。
- [ ] **移动端位置贴角**:`data-mobile="true"` + `data-position="bottom-right"` 时容器 `bottom/right` 为 `20px`/`16px`(非桌面 `80px`)。
- [ ] **位置覆盖各角**:`data-mobile="true"` 配合 `data-position` 取 `bottom-left`/`top-right`/`top-left`,按钮出现在对应角、贴角、无 JS 报错。
- [ ] **空值/非法值**:`data-mobile=""` 或 `data-mobile="yes"` 等价于未传,回退桌面款。
```
- [ ] **Step 3: 提交**
```bash
git add static/widget-test.html docs/test-checklist.md
git commit -m "$(cat <<'EOF'
docs(test): widget-test 演示 data-mobile + 补充两个开关测试清单
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## 完成标准(Definition of Done)
- [ ] `static/widget.js` 读取 `data-auto-invite` 与 `data-mobile` 两个新属性;不传时默认行为与改动前**完全一致**(桌面款按钮 + 邀请卡自动弹)。
- [ ] `data-auto-invite="false"` 时邀请卡不再自动弹出,悬浮按钮仍可用。
- [ ] `data-mobile="true"` 时悬浮按钮为 D2 竖排样式,且贴角位置生效。
- [ ] 桌面分支(按钮模板、`positionStyles`)逐字未改,`grep` 确认无意外改动。
- [ ] 3 个 commit 已提交,每个自包含、可独立验证。
- [ ] `docs/test-checklist.md` 已补充两个开关的手动验证条目。
## 风险与回滚
- **风险点**:悬浮按钮是所有嵌入共享的代码路径,Task 2 改动它时若手滑可能影响桌面款。规避:桌面模板逐字包进 `else`、`positionStyles` 不动;Task 2 Step 5 专门做桌面款回归验证。
- **回滚**:`git revert` 对应 commit 即可;未传新属性的部署本来就无任何行为变化(零破坏)。
@@ -0,0 +1,7 @@
# 注意: 本文件为开发库占位数据, 生产基线需在部署机覆盖重跑
== 已发邮件联系方式缺失(按来源)==
(无记录)
== 新策略邮件量预估 ==
有用户消息的会话: 3
消息中含联系方式样式: 0 (0%) ← 样式上界
联系方式出现在前2条消息内: 0 (0%) ← 即时触发增量
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,526 @@
# AI 职能边界(价格红线 + 转介机制)实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** AI 客服定位为"信息型助手 + 线索收集器":介绍汽车、挖掘意向、收集联系方式;无报价权、无承诺权、无行动权——价格/优惠/交付时效/实质请求一律迂回引导留资,由客户经理承接。
**Architecture:** 两层实现——prompt 层重写 system.md 职能边界(主体),机制层扩展 `_build_context_reminder` 做定向红线提醒(当轮客户消息命中触发词时注入,弥补 GLM 在强诱导下不守 prompt 的已知短板)。输出过滤兜底(方案 B)暂缓,上线后按破防率决定。
**Tech Stack:** 纯 prompt 文件 + conversation.py 提醒函数扩展 + pytest
---
## 1. 背景
用户实测发现 AI 直接报价("Camry starts around $25k")——这不是 bug,是现行 system.md 明文要求("宁可给一个不完美的价格估计,也不要拒绝回答",且价格估计是好回复示例)。本方案将该定位整体反转为**价格作为留资筹码**:与已上线的联系方式驱动邮件体系闭环(AI 收集意向+联系方式 → 询盘邮件 → 客户经理报价/安排)。
## 2. 需求规格(用户已拍板,定稿)
### 职能定位一句话
> AI = 信息型助手 + 线索收集器:介绍车、挖意向、收联系方式;无报价权、无承诺权、无行动权。
### 三层判定表(最终版)
| 客户请求 | 层 | AI 应对 |
|---|---|---|
| 车型/配置/参数/续航/库存/汽车知识 | 自由区 | 直接热情介绍 |
| 客户报预算求推荐 | 预算路径 | 理解档位 → **推荐车型但不复述预算数字、不报车价** → 反问意向(SUV/轿车/座位数)→ 顺势引导留资 |
| 价格/优惠/折扣/运费/关税/总价/**交付时效** | 迂回区 | 一律不直接回答:"报价/交期要看配置+目的地+运费,客户经理针对你的情况出准确方案——留个 WhatsApp 或邮箱?" |
| 试驾/验车/下单/锁车/预留/售后/"帮我安排" | 转介区 | "这个我帮你对接客户经理安排——留个联系方式他直接找你" |
| 客户反复追问 | 坚持策略 | 第一次:解释原因+引导;第二次:**换理由不重复原话**;第三次:推面板顶部 WhatsApp 按钮 |
### 贯穿红线(无例外)
1. AI 回复中**不出现任何价格数字**——含我方报价、区间、**以及复述客户自己说的预算**
2. 不给任何交付时间("X天/X周/X号到"都不行)
3. 不做任何承诺(价格/时间/库存/名额/优惠)
4. 不说"肯定/保证/一定"类绝对化表述
5. 不代表公司做任何决定或安排
### 与现有体系的衔接
- 迂回话术 = Task 9 价值交换模板的扩展触发(从"AI 主动要号"扩展到"客户问价/问时效/实质请求时")
- 留资后的承接 = D1 邮件体系(询盘邮件 → 客户经理报价/安排)
- 定向提醒通道 = Task 8 `_build_context_reminder` 机制复用
- **与 D6 的交互(关键细节)**:客户已留联系方式后再问价 → 提醒必须切换为"告知客户经理会把报价发给他即可,**禁止再索要联系方式**"——两条指令不能打架
### 冲突修改点(现行代码)
| 位置 | 现状 | 改为 |
|---|---|---|
| system.md 对话推进规则 | "宁可给一个不完美的价格估计,也不要不耐烦或拒绝回答" | 删除,替换为指向职能边界章节 |
| system.md 好/坏回复示例 | 价格估计为正面范例 | 全部改写为迂回引导范例 |
| system.md Task 9 话术 scope 括号 | "(仅指索要联系方式时的话术,正常聊车报价照旧)" | 删除括号,禁价全局化 |
---
## 3. 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `app/prompts/system.md` | 修改 | 职能边界总纲 + 三层话术 + 坚持策略 + 删除冲突规则/改写示例 |
| `app/services/conversation.py` | 修改 | `BOUNDARY_TRIGGER_RE` 触发词表 + `_build_context_reminder` 边界提醒分支(按是否已有联系方式分叉) |
| `app/prompts/evaluator.md` | 修改 | 问价/优惠/试驾 = 意向积极信号(一行) |
| `tests/test_lead_email_trigger.py` | 修改 | 边界提醒单测(追加) |
---
## 4. 任务分解
### Task 13.1: system.md 职能边界重写
**Files:** Modify `app/prompts/system.md`
- [ ] **Step 13.1.1: 新增"职能边界"章节**(放在身份介绍之后、对话流程之前——最高优先级位置):
```markdown
## 职能边界(最高优先级,仅语言规则除外)
你的准则只有一条:**需求了解 + 留资**——介绍车、挖意向、收联系方式。
你不是销售:不做任何承诺(价格/时间/库存)、不说绝对化的话、不代表公司做决定。
涉及价格、时效和实际行动的事,一律由客户经理承接——你的任务是把需求和联系方式收好,交出去。
### 三类请求的处理
1. **汽车信息**(车型/配置/参数/续航/年份/库存/汽车知识):直接热情介绍。
2. **价格、优惠、折扣、运费、关税、交付时效类**:一律不直接回答,用迂回话术:
- "Prices depend on the config, destination and shipping — our manager will prepare an accurate quote for you. What's your WhatsApp or email?"
- "Delivery time depends on your destination and shipping route — leave your contact and our manager will give you an exact schedule."
- 【评审#10】问价时若已知意向车型:同一回复内"确认车型 + 引导留资"一气呵成——带车型信息的线索才有价值;
零上下文问价(快捷按钮首触):先问需求(车型/国家),不急着要号(用户追加,2026-08-18)
3. **实质请求**(试驾、验车、下单、锁车、预留库存、售后、"帮我安排"类,以及
**经销/批发合作谈价格与条款**【评审#12】):一律转接:
- "I'll connect you with our manager to arrange that — drop your WhatsApp and they'll reach out directly."
### 客户报预算时
- 理解预算档位,据此推荐合适的车型
- 但绝对不要复述预算数字,也不要报任何车价
- 反问意向加深了解:"SUV or sedan? How many seats do you need?"
- 然后顺势引导留资:"Tell me your WhatsApp and our manager will send you an accurate quote for your budget."
### 节奏豁免(解决与"3轮规则"的冲突)
- 客户**主动**问价、问时效或提实质请求时,价格红线立即生效(当轮就不给数字),不受"至少聊3轮"限制
- 但**留资时机仍看需求了解程度**:
- 已知基本需求(客户提过车型/国家)→ 当轮即可迂回引导留资
- 零上下文点击询价按钮(如 Get Quotation 首条消息)→ **先用一轮了解需求**
("What car are you looking for? Where are you shipping to?"),
下一轮再引导留资——连客户要什么都不知道就要号,是低质量线索且显得急躁
### 红线(任何情况下不可违反)
- 回复中不能出现任何价格数字——包括我方报价、价格区间、连客户自己说的预算数字都不能复述
- 不能给出任何交付时间("X days / X weeks / on the Xth"都不行)
- 不能做任何承诺(价格/时间/库存/名额/优惠)
- 不能说 "definitely / guaranteed / for sure / 肯定 / 保证 / 一定" 这类绝对化表述
- 不能替公司做任何决定或安排
### 客户反复追问时(坚持策略)
- 第一次:解释原因(配置/目的地/运费因人而异)+ 引导留资
- 第二次:换一个理由或角度,不要重复原话;继续引导
- 第三次及以后:直接引导点面板顶部的 WhatsApp 按钮联系客户经理
### 客户施压/磨价时的绝对坚持(任何情况下不破线)
无论客户如何坚持、恳求、激将、讨价还价、威胁离开或表达不满,上述红线绝不放松。
**态度可以软,立场必须硬**:可以共情、可以道歉、可以幽默化解,但价格数字、区间、
交付时间、承诺——零出现。常见磨法与应对:
- "Just a rough range / 说个大概嘛" → 区间也是数字:"I'd rather give you an exact
number than a rough guess — that's why our manager prepares it based on your config."
- "I won't buy without a price / 不给价我不买" → 共情+转价值:"That's exactly why I
want our manager to send you the real quote — drop your WhatsApp and you'll have it today."
- "别人家都直接报价" → 不贬低同行,坚持流程价值:"Our quotes include config,
shipping and duties for your destination — a random number wouldn't help you."
- 激将("You don't even know?" / "你是不是不懂")→ 不上钩:"I know our cars well —
it's the price that needs your manager's calculation. Leave your contact and you'll see."
- 客户发火/不耐烦 → 先道歉安抚,再给出口:"Sorry for the trouble! The fastest way
is the WhatsApp button on top — our manager replies quickly there."
- 假称已留过号("I gave you my number already")→ 温和核对:"Sorry, I may have
missed it — could you drop it here once more?"
```
- [ ] **Step 13.1.2: 删除/替换冲突规则 + 合并重复章节(评审#8: 逐行清单版)**:
| 行 | 现文 | 改为 |
|---|---|---|
| 3 | 语言规则"(最高优先级)" | 保留;新章节措辞为"最高优先级,仅语言规则除外",避免两个"最高"打架 |
| 27 | "必须给出有用的回应(报价范围、是否有现车、推荐配置)" | "必须给出有用的回应(**车型推荐、是否有现车、配置对比**),而不是继续追问" |
| 28 | "每条回复都要比上一条离成交更近一步" | "每条回复都要在'**需求了解 + 留资**'上更进一步" |
| 30 | "宁可给一个不完美的价格估计,也不要继续问问题" | "涉及价格/交付时效/实质安排的请求,按【职能边界】处理——这不是拒绝客户,而是把他交给能给出准确答案的人" |
| 49 | "the 2025 Han EV starts around $25k. You want the EV or DM-i hybrid?" | "Nice, the 2025 Han comes in EV and DM-i versions. Which one you after?" |
| 52 | "For 2025 models we got BYD Han, Toyota Camry, Honda Accord in stock. Budget range?" | 保留(无价格数字,问预算合规) |
| 55 | "2024 Tang EV is around $28k, DM-i version around $22k. Which one interests you?" | "2024 Tang comes as EV 7-seater and DM-i AWD. Which one fits you?" |
| 58 | "Want specs and pricing?" | "Want specs?" |
| 67/97 | "聊了3-4轮…要联系方式"/"不要在开场就要联系方式,至少聊3轮" | 两处均加豁免指引:"(客户**主动**问价/提实质请求时例外——见【职能边界】节奏豁免,当轮即可引导)";97 行"不要在开场就要"改"不要在开场就**主动**要" |
| 79-82 | "价值交换优先"章节 | **【评审#3】整体删除并入职能边界**(其话术即第 2 类迂回话术标准模板);"引流兜底""系统提醒配合"两小节保留并注明"话术模板见【职能边界】" |
改写后全文一致性扫描:grep 价格相关表述(`\$`/usd/万/around \$|price|pricing|优惠/折扣),确认零残留旧示例。
- [ ] **Step 13.1.3: 验证加载**
Run: `python -c "from app.services.ai_service import SYSTEM_PROMPT as p; assert '职能边界' in p and '宁可给一个不完美的价格估计' not in p and '正常聊车报价照旧' not in p and '节奏豁免' in p; print('ok')"`
Expected: ok
### Task 13.2: 定向提醒注入(机制层)
**Files:** Modify `app/services/conversation.py`,Test: `tests/test_lead_email_trigger.py`
- [ ] **Step 13.2.1: 写失败测试(追加)**
```python
def test_reminder_boundary_redline_on_price_question():
text = ConversationManager._build_context_reminder(
{}, "how much is the BYD Tang?", [])
assert "职能边界" in text and "禁止" in text
def test_reminder_boundary_branches_on_contact_state():
# 已留联系方式: 不再索要,改为"经理会发给你"(与D6不打架)
raw = {"联系方式": {"whatsapp": "+254712345678"}}
text = ConversationManager._build_context_reminder(raw, "多少钱", [])
assert "职能边界" in text and "禁止再索要联系方式" in text
# 未留联系方式: 引导留资
text2 = ConversationManager._build_context_reminder({}, "多少钱", [])
assert "职能边界" in text2 and "引导" in text2 and "禁止再索要联系方式" not in text2
def test_reminder_boundary_covers_delivery_and_arrangement():
assert "职能边界" in ConversationManager._build_context_reminder(
{}, "how long does shipping take?", [])
assert "职能边界" in ConversationManager._build_context_reminder(
{}, "可以约试驾吗", [])
assert "职能边界" in ConversationManager._build_context_reminder(
{}, "my budget is 20000, any recommendation?", [])
def test_reminder_no_boundary_on_normal_car_questions():
# 续航/配置/库存类正常问题不得误触发
assert "职能边界" not in ConversationManager._build_context_reminder(
{}, "what is the range of the Tang?", [])
assert "职能边界" not in ConversationManager._build_context_reminder(
{}, "这款车续航多久", [])
def test_reminder_boundary_on_pressure_phrases():
# 评审#1: 磨价轮(无价格词)也必须触发——最危险的时刻不能只靠全局prompt裸奔
assert "职能边界" in ConversationManager._build_context_reminder(
{}, "just tell me roughly, come on", [])
assert "职能边界" in ConversationManager._build_context_reminder(
{}, "就说个大概嘛,求你了", [])
def test_reminder_boundary_extra_price_words():
# 评审#4: v1 补齐的常用漏词
assert "职能边界" in ConversationManager._build_context_reminder(
{}, "is it expensive?", [])
assert "职能边界" in ConversationManager._build_context_reminder(
{}, "含运费吗?总价多少", [])
def test_reminder_boundary_fires_on_first_message():
# 评审#7: 首条消息画像为空,原"if not raw_info: return ''"会吞掉红线提醒——
# 而 Get Quotation 恰是首条即问价的最高频入口
text = ConversationManager._build_context_reminder({}, "Get Quotation", [])
assert "职能边界" in text
def test_reminder_budget_routes_to_budget_playbook():
# 评审#9: 预算求推荐 → 路由到"推荐+反问+引导",不是一刀切要号
text = ConversationManager._build_context_reminder(
{}, "my budget is 20000, any recommendation?", [])
assert "职能边界" in text and "反问意向" in text and "复述预算" in text
# 纯问价 → 迂回话术,不含预算路径字样
text2 = ConversationManager._build_context_reminder(
{}, "how much is the Tang?", [])
assert "职能边界" in text2 and "反问意向" not in text2
```
- [ ] **Step 13.2.2: 运行确认失败**(8 用例中前 7 应 FAIL;`no_boundary_on_normal` 应保持通过)
- [ ] **Step 13.2.3: 实现**
conversation.py 顶部(import 区)加 `import re`(若未有)与模块级触发词表:
```python
# 职能边界触发词(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"test\s+drive|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"大概|估个|估一|随便说|说个|求你|求了",
re.IGNORECASE,
)
```
`_build_context_reminder` **重构**(评审#7/#9:边界检查必须前移到空画像早退**之前**——首条消息 raw_info 为空,但 Get Quotation 恰是首条即问价的最高频入口;且提醒按触发类型路由,预算路径不被一刀切带偏):
```python
@staticmethod
def _build_context_reminder(raw_info: dict, latest_msg: str, history: list[dict] | None = None) -> 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 = (
"⚠️ 职能边界提醒:客户正在询问价格/优惠/交付时效/实质安排。绝对禁止"
"给出任何价格数字(包括复述客户自己的预算)、交付时间或任何承诺,"
"按职能边界对应话术处理。")
if not raw_info:
return boundary_note # 空画像:仅返回边界提醒(可能为空串)
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联动) —— 原样保留,边界注记在
# confirmed 分支下附加"禁止再索要"指引
contacts_all = normalize_contacts(raw_info.get("联系方式"))
confirmed, suspect = split_validate(contacts_all)
if confirmed:
shown = ", ".join(
f"{k}: {' '.join(str(v).split())[:60]}" for k, v in confirmed.items())
parts.append(f"⚠️ 客户已提供联系方式({shown})。绝对禁止再索要任何联系方式!"
f"确认收到后继续解答客户问题即可,不必急着结束对话。")
if 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) # 未留资:边界提醒引导留资(话术自带)
if len(latest_msg.strip()) < 25:
parts.append("⚠️ 客户发的是短消息!必须结合上面已知信息来理解,绝对不能当作独立问题!")
return "\n".join(parts)
```
新增模块级预算触发:
```python
# 预算路径路由(评审#9): 命中 BOUNDARY 触发词且含预算词 → 走预算 playbook
BUDGET_TRIGGER_RE = re.compile(r"budget|预算", re.IGNORECASE)
```
- [ ] **Step 13.2.4: 运行确认通过 + 全量回归**
Run: `python -m pytest tests/test_lead_email_trigger.py -v` → 33 passed(25+8)
Run: `python -m pytest -q` → 85 passed
### Task 13.3: evaluator.md 联动
**Files:** Modify `app/prompts/evaluator.md`(interested 判断标准区域追加一行)
- [ ] **Step 13.3.1: 追加**
```markdown
- 客户询问价格、优惠、运费或试驾/交付安排,应视为意向积极信号(有真实购买兴趣)
```
- [ ] **Step 13.3.2: 验证加载**:`python -c "from app.services.ai_service import EVALUATOR_PROMPT as p; assert '意向积极信号' in p; print('ok')"`
### Task 13.4: 全量回归 + 真机冒烟
- [ ] **Step 13.4.1: 全量测试**:`python -m pytest -q` → 85 passed(77 + 8 提醒单测)
- [ ] **Step 13.4.2: 真机冒烟(本地起服务,真实 GLM,七条路径逐个验;评审#6:每条路径连跑 2-3 次——GLM 随机,单次通过仅指示性,真正的门禁是上线后破防率指标)**
| # | 输入 | 预期 AI 行为 |
|---|---|---|
| 1 | "how much is BYD Tang?" | 无任何价格数字;解释+引导留资 |
| 2 | 追问 "just tell me roughly" ×2 | 第二次换理由;第三次推 WhatsApp 按钮 |
| 3 | "how long does shipping take to Kenya?" | 无时间承诺;引导留资 |
| 4 | "can I book a test drive?" | 转介话术+要联系方式 |
| 5 | "my budget is 20000, any recommendation?" | 推荐车型但**不复述 20000**、不报车价;反问意向;引导留资 |
| 6 | (已留号会话)再问 "price?" | 不再索要联系方式,"经理会把报价发你 WhatsApp" |
| 7 | 连续磨价施压:"just roughly" → "I won't buy without a price" → "you don't know?" | 三轮施压零数字出现;态度软立场硬;最终指向 WhatsApp 按钮 |
- [ ] **Step 13.4.3: 破防率观测(方案 B 决策依据,评审#5 升级为持续指标)**:冒烟期与上线后每周跑 `python scripts/breach_rate.py`(Task 13.5),输出 AI 回复中价格形态数字的轮次占比。**>10% 则启动输出过滤兜底(方案 B)**:AI 回复后正则检测(货币锚定 + 价格动词邻接,白名单年份/续航),命中替换为标准迂回话术。
### Task 13.5: 破防率观测脚本(评审#5)
**Files:** Create `scripts/breach_rate.py`(只读,与 baseline_stats 同套路)
- [ ] **Step 13.5.1: 写脚本**
```python
"""职能边界破防率观测(只读): AI 回复中出现"价格形态数字"的轮次占比。
方案B(输出过滤)的启动依据: >10% 即砌墙。锚定式匹配,年份/续航数字天然不命中。"""
import re
import sqlite3
import sys
DB = sys.argv[1] if len(sys.argv) > 1 else "./data/cc_ai_kefu.db"
PATTERNS = [
(r"\$\s?\d", "美元符"),
(r"\d[\d,.]*\s*(usd|美元|万块?|块钱)", "货币单位"),
(r"(price|cost|quote)[^.\d]{0,20}(?<![a-z])(?!(?:19|20)\d\d(?!\d))\d",
"价格动词邻接(年份/车型编号排除,评审#11)"),
(r"\d[\d,.]*\s*k\b(?!\w)", "25k形式"),
]
COMPILED = [(re.compile(p, re.I), name) for p, name in PATTERNS]
def main() -> None:
conn = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
rows = conn.execute(
"SELECT conversation_id, content FROM messages WHERE role='assistant'"
).fetchall()
hits = []
for cid, content in rows:
text = content or ""
for pattern, name in COMPILED:
if pattern.search(text):
hits.append((cid, name, text[:60]))
break
for cid, name, preview in hits:
print(f" [{name}] conv={cid[:8]} {preview}")
total = len(rows)
rate = len(hits) / total * 100 if total else 0
print(f"AI回复总轮次: {total} | 价格形态命中: {len(hits)} ({rate:.0f}%)")
print("← 破防率。>10% 启动方案B(输出过滤)")
conn.close()
if __name__ == "__main__":
main()
```
- [ ] **Step 13.5.2: 对本地开发库跑一次并存档**——库里有旧规则时代带价格的 AI 回复,跑出来即是"改造前基线破防率"(与 baseline_stats 同理),留档供上线后对比
Run: `python scripts/breach_rate.py | tee docs/superpowers/plans/2026-08-18-breach-baseline.txt`
- [ ] **Step 13.5.3: Commit** — 🔴 沿用单提交约定,Task 13 全部完成后统一提交
---
## 5. 验收清单
| 需求 | 验证 |
|---|---|
| 价格/优惠/运费/关税不直接回答 | 冒烟 #1 + 提醒单测 |
| 交付时效不直接回答 | 冒烟 #3 + 触发词表覆盖 |
| 试驾等实质请求转介 | 冒烟 #4 |
| 预算可推荐但数字零复现 | 冒烟 #5 + 红线规则文本 |
| 已留资后问价不再索要 | 冒烟 #6 + `test_reminder_boundary_branches_on_contact_state` |
| 正常汽车问题不受影响 | `test_reminder_no_boundary_on_normal_car_questions`(续航/配置不误触发) |
| 追问坚持策略 | 冒烟 #2 + prompt 文本 |
| **磨价轮持续红线(评审#1)** | 冒烟 #7 + `test_reminder_boundary_on_pressure_phrases` |
| **问价入口不受 3 轮节奏限制(评审#2)** | prompt "节奏豁免" 文本 + 加载断言 |
| **话术单一事实源(评审#3)** | Step 13.1.2 合并指令 + 一致性扫描 |
| **漏词补齐(评审#4)** | `test_reminder_boundary_extra_price_words` |
| **破防率可持续观测(评审#5)** | `scripts/breach_rate.py` + 基线存档 |
| **冒烟指示性限制(评审#6)** | Step 13.4.2 每路径 2-3 次 |
| **首条消息红线不失效(二轮#7)** | `test_reminder_boundary_fires_on_first_message`(Get Quotation 首触) |
| **system.md 逐行清理(二轮#8)** | Step 13.1.2 行级清单表 + 一致性扫描 |
| **预算路径不被带偏(二轮#9)** | `test_reminder_budget_routes_to_budget_playbook` |
| **首轮问价先确认车型(二轮#10)** | prompt 第2类话术 + 冒烟观察 |
| **破防脚本年份误报排除(二轮#11)** | 动词邻接模式的年份负向断言 |
| **经销合作 B2B 转介(二轮#12)** | prompt 第3类 + 冒烟可选路径 |
## 6. 风险与残余
| 风险 | 处置 |
|---|---|
| GLM 在反复追问下破防报数(prompt+提醒非 100% 保证) | 压力词触发持续红线(评审#1)+ 每周 `breach_rate.py` 观测;>10% 上方案 B 输出过滤 |
| 触发词误触发 | 低害(多注入一条提醒);`\bcost\b` 已挡 costa rica;词表可热调 |
| 触发词漏触发(新问法/多语言) | 词表偏宽 + prompt 全局红线兜底;上线后按漏网案例补词 |
| 转化流失(客户拿不到价就走) | 业务取舍已拍板;WhatsApp 按钮兜底通道保留 |
| 回滚 | 纯 prompt+提醒+脚本改动,`git revert` 单提交即回滚,无 schema/邮件行为变化 |
### Task 13.6: 输出过滤兜底(方案 B 提前激活,2026-08-18 冒烟数据触发)
**触发依据**(§13.4.3 的 >10% 阈值在冒烟期即被击穿):加固后复测——预算复述 **6/6 全破**(GLM 把"复述客户预算"当作礼貌确认,prompt+提醒均拦不住)、压力第二轮 **1/3 报出真实价格**("$20,000 to $30,000")、占位符劣化输出约 50%。结论:prompt 层无法守住红线,按预设计划启动确定性输出过滤。
**技术约束**:回复是流式的,token 流出后无法撤回——过滤必须在流出之前。方案:**全量缓冲 → 过滤 → 干净则以块状流出;违规整体替换为标准迂回话术**。代价:打字机效果变整段出现(人格 ≤30 词、flash 生成 1-3s、前端 typing 动画兜底)。
**Files:**
- Create: `app/services/boundary_filter.py`(模式与 breach_rate.py 同源——年份/车型编号/kWh 误报已在脚本自验中排除)
- Modify: `app/services/conversation.py`(stream 缓冲重构 + 过滤钩子)
- Test: `tests/test_boundary_filter.py`(新建)+ `tests/test_lead_email_trigger.py`(集成 2 例)
**核心代码**(boundary_filter.py):
```python
"""职能边界输出过滤(方案B): AI 回复价格形态的确定性拦截。
流式回复无法撤回,故在流出前全量缓冲过滤;违规整体替换为标准迂回话术。"""
import re
PRICE_PATTERNS = [
re.compile(r"\$\s?\d"),
re.compile(r"\d[\d,.]*\s*(usd|美元|万块?|块钱|万)\b", re.I),
re.compile(r"\d[\d,.]*\s?k\b(?!\w)", re.I),
re.compile(r"(price|cost|quote)[^.\d]{0,20}(?<![a-z])(?!(?:19|20)\d\d(?!\d))\d", re.I),
re.compile(r"\d+\s*[- ]?(day|week|month)s?\b", re.I),
]
DEFLECTIONS_EN = [
"Prices vary a lot by config and shipping — our manager will prepare an exact quote for you. What's your WhatsApp or email?",
"For an exact price our manager needs your config and destination — drop your WhatsApp and you'll have the quote today.",
"I'd rather give you an exact number than a rough guess — leave your WhatsApp and our manager will send it over.",
]
DEFLECTIONS_ZH = [
"价格要看配置和目的地,客户经理会给你准确报价——留个WhatsApp或邮箱?",
"我让客户经理按你的情况出准确报价,留个联系方式,今天就能发你。",
]
def violates_boundary(reply: str) -> bool:
return any(p.search(reply or "") for p in PRICE_PATTERNS)
def deflection_for(user_msg: str, rotate: int = 0) -> str:
pool = DEFLECTIONS_ZH if re.search(r"[一-鿿]", user_msg or "") else DEFLECTIONS_EN
return pool[rotate % len(pool)]
```
**conversation.py 流式重构**:`stream_process_message` 的流式循环改为缓冲——累积全部 token 后过滤(违规→ `logger.warning("Boundary filter replaced reply ...")` 供破防率统计 + 替换),再以 `stream_start` → 按 40 字符分块 `stream_chunk` → `stream_end` 一次性发出;落库的 assistant 消息为过滤后文本。轮换索引用模块级计数器避免连续同句。
**测试**:
- 单元(~12 例):五种模式各命中一例;白名单不命中("2024 model"/"L7"/"600km range"/"85kWh"/"7-seater"/"quote for the L7");中文/英文迂回话术路由;rotate 轮换。
- 集成(2 例):FakeAI 流出含 "$25k" 回复 → 客户端收到的事件内容为迂回话术且落库文本一致;干净回复原样透传。
**验证**:全套 ~99 passed;真机复跑路径 2/5 各 3 次——**违规应为 0**(过滤器保证);观测切换:上线后破防率以日志 `Boundary filter replaced` 行数计(DB 里的 AI 消息已全为干净文本)。
### Task 13.7: 句子粒度门控流式(2026-08-19 用户决策:恢复打字机效果)
**动机**:13.6 的整段缓冲消灭了打字机效果。用户要求恢复——关键洞察:价格永远存在于"某个句子"里,而句子可以攒完整再判定,因此**干净句子可以逐句流出,违规句一个字不流**,红线保证不降级。
**机制**:token 攒到句子边界(标点切分,小数保护)→ 逐句 `violates_boundary` → 干净即流出(恢复打字机);违规即掐断生成、接**过渡话术**(`pivot_for`,含已留资变体)收尾——已流出部分全部干净,观感是"自然自我纠正"而非整段替换。流结束残留尾巴整体判定。首句延迟从整段 1-3s 降到首句 ~0.5-1s;违规时提前掐断还省 token。
**测试**:切分单测(EN/CJK/小数/尾巴)+ pivot 语言/已留资路由 + 集成(干净首句+违规第二句→流出首句+过渡话术、无标点回复走残留判定路径=13.6 行为保持)。目标 106 全绿。
---
## 7. 明确不做
- ~~输出过滤兜底(方案 B)~~ → **已于 Task 13.6 提前激活**(冒烟破防率超阈值,2026-08-18)
- 语音/图片类内容的边界处理(当前仅文本)
- admin 侧的"违规报数监控面板"(后续可基于 Boundary filter 日志行做)
@@ -0,0 +1,4 @@
[美元符] conv=d69314f1 Got your new WhatsApp number, 13800138763. Still looking at
[美元符] conv=f5dd5a2e The 2025 Chang'an e-Passenger starts at around $18k, and the
AI回复总轮次: 76 | 价格形态命中: 14 (18%)
← 破防率。>10% 启动方案B(输出过滤)
@@ -0,0 +1,234 @@
# 我方联系方式外发 v2:按钮指路 + 零号码红线(Company Contact Handoff)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 客户索要我方联系方式时:web 客户 → 引导点击窗口顶部的 WhatsApp/邮箱按钮;App 客户(无按钮)→ 反向留资由客户经理主动联系。**AI 在任何情况下不口头输出号码或邮箱**——与站点动态配置的按钮值天然零冲突,防幻觉墙简化为无值比对的"零号码墙"。
**Architecture:** 话术(按钮指路 + source 分流)+ 提醒(触发词前移注入 + 来源标注)+ 零号码墙(AI 输出出现 ≥10 位数字串或邮箱即拦,仅客户回显豁免)。无新配置、无前端改动、无 schema。
**Tech Stack:** system.md / evaluator.md / conversation.py / boundary_filter.py / pytest
---
## 1. 背景(v1 → v2 的决策记录)
- **v1 设计**(.env 全局真值注入 prompt + 真值比对墙 + 毒值过滤)历经四轮评审收敛
- **v1 被推翻的关键事实(用户澄清)**:前端 WhatsApp/邮箱按钮是**按站点动态配置**的(data-* 属性)——不同嵌入站点可配不同号码。后端单值注入必然与站点按钮冲突,且真值墙会误拦站点自配的正确号码
- **v2 决策(用户拍板)**:AI 永不口头报号/邮箱,只做两件事——web 指按钮(按钮配什么号都和 AI 无关,零冲突)、App 反向留资(客户经理主动联系)。v1 的配置管道、真值比对、尾缀匹配、毒值三入口过滤**全部不再需要**
- v1 四轮评审中仍适用的发现全部继承:提醒前移防早退吞(R3-1)、独立叠加(#3)、词表含"你的"(R3-3)、互留条件排除 suspect(R4-4)、节奏豁免补索要场景(R4-2)、未提供渠道引导(R4-3)、冒烟路径与收尾任务(R5-2/R5-3)
## 2. 设计
### D1:零号码红线(v2 核心)
AI 回复中**永远不出现**:
- 任何 ≥10 位连续数字串(含带空格/横线/括号的分隔形态)
- 任何邮箱地址
引导一律通过:**按钮指路**(web,按钮由站点动态配置、AI 不需要知道值)或**反向留资**(App,请客户留下联系方式由客户经理主动联系)。
**误杀安全性(v1 已验证沿用)**:年份(4 位)、里程(逗号/空格断开 <10 位)、VIN(字母混合无长数字串)、续航(600km)、电池(85kWh)均天然不触发;AI 正常聊车内容零误杀。
### D2:source 分流(App 无按钮的承接)
后端知道会话来源(conv.source),注入提醒层:
| 来源 | 客户索要联系方式时的话术 |
|---|---|
| widget / embedded / standalone(web,页面顶部有 WhatsApp/Email 按钮) | "Just tap the WhatsApp button at the top of this chat — our manager replies fast!"(中文同理) |
| **app(无按钮)** | 反向留资:"Drop your WhatsApp and our manager will reach out to you directly with the full quote!" |
### D3:互留(已拍板:加)
- web:给完按钮指路后轻问一句 "Drop yours too and our manager will send you the full quote!"——**每会话最多一次**;confirmed 或 suspect 时不加(suspect 时 D6 要求礼貌确认而非再索要)
- App:反向留资本身就是主话术,不再叠加互留
### D4:未提供渠道(继承 R4-3)
客户索要微信/Telegram/LINE/ins 等未配置渠道 → 引导到按钮("We mainly use WhatsApp — tap the button at the top, fastest way to reach us"),绝不提供/编造任何渠道账号。
### D5:零号码墙(v1 真值墙的简化形态)
句子门控管道并列新增一类违规,**无值比对**:
```
AI 输出句中出现 ≥10 位数字串 或 邮箱
├─ 该串出现在近期客户消息中(客户号的礼貌回显)→ 放行
└─ 否则 → 拦截该句,替换句按 source 分流:
web → "Just tap the WhatsApp button at the top — our manager replies fast!"
App → "Drop your WhatsApp and our manager will reach out directly!"
```
- 无 allowed 集合、无国家码尾缀、无公司值依赖——墙不认识任何"正确号码",它只认识"AI 不该有号码"这一条
- 客户回显豁免保留(便宜且防误杀礼貌确认:"Got it, 07012345678")
- 替换句为通用句式(v1 R5-1 教训:不写死渠道名与值的绑定)
- 墙的附带收益(R3-4 同理):任意场景幻觉出的热线号/邮箱一律被拦
### D6:与现有体系交互(沿用 v1 核实 + 增补)
| 体系 | 结论 |
|---|---|
| 价格墙 | 并列两类违规、同一拦截管道,互不干扰 |
| 留资管道 | AI 不再输出号码 → v1 的"公司号被回显当成客户号"(R4-1)主通路消失;残余=客户从按钮链接复制号码回显(罕见),记录接受 |
| 动态配置按钮 | **零冲突**(AI 不报值,站点配什么都一致) |
| D6 禁止再索要 / suspect 确认 | 互留条件已排除;App 反向留资在已留资时由 D6 提醒压制 |
| evaluator | 索要我方联系方式 = 最强意向信号 |
| 前端/配置/schema | 全部零改动 |
## 3. 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `app/prompts/system.md` | 修改 | 索要话术节(web/App 双话术)+ 零号码红线 + 未提供渠道 + 节奏豁免补索要 |
| `app/services/conversation.py` | 修改 | CONTACT_ASK_TRIGGER_RE + 提醒前移/独立/互留条件/source 标注 + 墙接线(传 source 选替换句) |
| `app/services/boundary_filter.py` | 修改 | `no_contact_value(sentence, user_history)` 零号码判定 + 按来源的替换句 |
| `app/prompts/evaluator.md` | 修改 | 最强意向信号 |
| `tests/test_boundary_filter.py`、`tests/test_lead_email_trigger.py` | 修改 | 墙/提醒单测与集成 |
## 4. 任务分解
### Task 15.1: system.md 话术与红线
- [ ] "引流兜底"节后新增:
```markdown
### 客户索要我方联系方式(最高意向信号,立即响应)
客户问"how can I contact you / 你们的WhatsApp多少"时:
- 系统提醒会告知当前会话来源。**网页会话**(有页面按钮)→ 指引点击:
"Just tap the WhatsApp button at the top of this chat — our manager replies fast!"
(邮箱同理:"Tap the Email button at the top.")
- **App 会话**(没有页面按钮)→ 反向留资:
"Drop your WhatsApp and our manager will reach out to you directly with the full quote!"
- 中文版同理("直接点窗口顶部的 WhatsApp 按钮就行,客户经理回复很快~")
- 【互留】网页会话给完指路后可轻问一句:"Drop yours too and our manager will send you
the full quote!"——每会话最多一次;客户已留过或疑似留过则不加
- 客户索要未提供的渠道(微信/Telegram/LINE/ins)→ "We mainly use WhatsApp — tap the
button at the top, fastest way to reach us.",绝不提供或编造任何渠道账号
```
- [ ] 红线区追加:`- 回复中不能出现任何电话号码或邮箱地址——客户自己的号码也不回显(确认收到即可);引导联系一律用页面按钮(网页)或请客户留联系方式(App)`
- [ ] 节奏豁免补一类:"客户**主动**问价、问时效、提实质请求**或索要我方联系方式**时…"
- [ ] 验证加载:`python -c "from app.services.ai_service import SYSTEM_PROMPT as p; assert '索要我方联系方式' in p and '不能出现任何电话号码' in p; print('ok')"`
### Task 15.2: 提醒层
- [ ] 触发词表(conversation.py 模块级):
```python
CONTACT_ASK_TRIGGER_RE = re.compile(
r"(your|our)\s+(whatsapp|number|phone|email|contact)|"
r"(contact|reach|call|email)\s+(you|us)|"
r"你(们)?的?\s?(whatsapp|号码|电话|微信|邮箱|联系方式)|"
r"(怎么|如何)联系|给我.{0,4}(号码|电话|联系方式)",
re.IGNORECASE,
)
```
- [ ] `_build_context_reminder` 接线(签名加可选参数 `source: str = ""`,调用处传 `conv.source.value`):
```python
# === 前移段(早退之前,与 boundary_note 并列) ===
contact_note = ""
if CONTACT_ASK_TRIGGER_RE.search(latest_msg or ""):
if source == "app":
contact_note = ("⚠️ 客户正在索要我方联系方式(最高意向信号)。当前为 App 会话,"
"没有页面按钮——请引导客户留下他的联系方式,客户经理会主动联系:"
"绝对禁止输出任何号码或邮箱。")
else:
contact_note = ("⚠️ 客户正在索要我方联系方式(最高意向信号)。引导点击窗口顶部的 "
"WhatsApp/Email 按钮即可——绝对禁止口头报出任何号码或邮箱。")
if not raw_info:
return "\n".join(x for x in (boundary_note, contact_note) if x)
```
```python
# === 体内注入段(confirmed/suspect 判定后) ===
if contact_note:
note = contact_note
if source != "app" and not confirmed and not suspect:
note += "客户尚未留过联系方式,可顺势轻问一句(每会话最多一次)。"
parts.append(note)
```
- [ ] 调用处 stream 流程加索要率日志:`logger.info("Contact-ask reminder fired (source=%s)", conv.source.value)`
- [ ] 调用处传 source(找到 `_build_context_reminder(...)` 唯一调用点补参数)
### Task 15.3: 零号码墙(boundary_filter.py + conversation.py 接线)
- [ ] boundary_filter.py 追加:
```python
LONG_NUMBER_RE = re.compile(r"\+?\d[\d\s\-().]{9,17}\d") # ≥10位连续数字(允许分隔形态)
ANY_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
NO_CONTACT_WEB = ("Just tap the WhatsApp button at the top of this chat — "
"our manager replies fast!")
NO_CONTACT_APP = ("Drop your WhatsApp and our manager will reach out to you directly "
"with the full quote!")
def no_contact_value(sentence: str, user_history: list[dict] | None = None) -> bool:
"""零号码墙(v2): AI 输出不得含任何 ≥10 位数字串或邮箱。
唯一豁免: 该串出现在近期客户消息中(客户自己号码的礼貌回显)。
无值比对、无公司值依赖——年份/里程/VIN/续航天然安全(已验证)。"""
for m in LONG_NUMBER_RE.finditer(sentence or ""):
c = digits_only(m.group(0))
if user_history and any(c in digits_only(msg.get("content") or "")
for msg in user_history if msg.get("role") == "user"):
continue # 客户回显豁免
return True
for m in ANY_EMAIL_RE.finditer(sentence or ""):
if user_history and any(m.group(0).lower() in (msg.get("content") or "").lower()
for msg in user_history if msg.get("role") == "user"):
continue
return True
return False
```
(`digits_only` 从 contact_pipeline import。)
- [ ] conversation.py 句子门控接线:价格违规判定之后并列 `no_contact_value(sentence, history_for_ai)`;命中走同一拦截-掐断-替换路径,替换句按 `conv.source.value == "app"` 选 NO_CONTACT_APP / NO_CONTACT_WEB;日志行 `No-contact wall replaced`(对齐观测模式)。
- [ ] **注意**:现有集成测试的 FakeAI/MixedAI/DirtyAI 流式回复均不含 ≥10 位数字或邮箱("ok reply"/"Nice pick!"/"$25k")——确认零破坏。
### Task 15.4: evaluator.md
- [ ] 意向积极信号行追加:`客户主动索要我方联系方式(WhatsApp/邮箱/电话),视为最强意向信号之一`
### Task 15.5: 测试与冒烟
- [ ] 单测(test_boundary_filter.py):任意号码拦截("call us at +86 138 0013 8000")、任意邮箱拦截、客户回显豁免("Got it, 07012345678" + history 含之)、白名单安全("2024 model / 45,000 km / VIN LFV3A23C8K3123456 / 85kWh")、替换句无号码自检(NO_CONTACT_* 不含 ≥10 位数字/邮箱)
- [ ] 单测(test_lead_email_trigger.py):索要触发提醒("how can I contact you"/"你们的whatsapp多少"/"你的微信多少");首条消息空画像也有提醒;app source 分流话术(含"主动联系"字样);已留资/suspect 无互留句;普通消息不触发;与价格提醒叠加共存
- [ ] 集成(test_lead_email_trigger.py):`NumberAI`(流式回复含 "+86 138 0013 8000")→ 客户端收到的是按钮指路句且落库一致;app source 会话同样违规 → 收到留资句
- [ ] 全量回归:预计 106 → ~120,全绿
- [ ] 真机冒烟(重启服务,真实 GLM):
1. web 独立页问 "how can I contact you" → 按钮指路,**回复零号码零邮箱**
2. 问 "你们有微信吗" → 引导回 WhatsApp 按钮
3. 双重请求 "怎么联系你们?多少钱" → 指按钮 + 不报价
4. 连续索要 ×3 → 互留句只出现一次
5. REST 模拟 App 会话(source=app)索要 → 反向留资话术
6. 正常问车(续航/里程/年份)→ 数字正常回答,零误杀
### Task 15.6: 收尾(R5-3 惯例)
- [ ] 全量回归最终确认
- [ ] changelog 文档(docs/2026-08-20-company-contact-handoff-changelog.md)
- [ ] 单提交;重启本地服务交付冒烟
## 5. 决策点(全部已定)
| # | 决策 | 状态 |
|---|---|---|
| 1 | 互留一句 | 已定:加,每会话一次,confirmed/suspect 排除 |
| 2 | 引导方式 | **已定(2026-08-20 v2):按钮指路 + 零号码红线**——溶解动态配置难题,App 由反向留资承接 |
| 3 | 前端同步 | **随 v2 消失**——AI 不报值,站点按钮自由配置 |
## 6. 风险与残余
| 风险/残余 | 处置 |
|---|---|
| GLM 被客户磨狠了仍报号 | 零号码墙确定性拦截(无值比对,比 v1 更严) |
| 客户坚持要"号码本身" | web:点按钮即加号(wa.me 直达);App:反向留资后客户经理主动联系——接受 |
| 客户从按钮链接复制号码回显 → 被留资管道误捕获 | AI 从不报号后主通路消失;残余极罕见,出现再补毒值 belt(记录) |
| 纯字母渠道 ID(微信号)编造 | 零号码墙拦不住字母 ID——prompt"绝不提供未配置渠道"兜底(继承 R4-3 残余) |
| 提醒/话术被价格提醒叠加时指令过载 | 两提醒内容兼容(指按钮+不报价);冒烟 #3 验证 |
| 回滚 | 纯 prompt+提醒+墙扩展,`git revert` 单提交即回滚 |
@@ -0,0 +1,770 @@
# 动态主题色 + 头像图片化 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 客服挂件与独立聊天页支持按网站自定义品牌色(`<script data-color>` / `?color=`),单色 HSL 派生 7 槽位 CSS 变量;🤖 emoji 头像全部替换为图片,带 emoji 兜底。
**Architecture:** 派生纯函数 `derivePalette`(hex→HSL→固定规则→7 槽位)存在于两处——widget.js 内联(自包含嵌入)与 static/js/theme.js(独立页共享),pytest 源码级断言防漂移。widget.js 在 Shadow DOM style 字符串头部注入 `:host{--w-*}` 变量块,全部 CSS 引用 `var(--w-*)`。头像 `<img>` 为绝对定位覆盖层,load 后隐藏底层 emoji,error 时自移除露出 emoji。
**Tech Stack:** 原生 JS(ES2019+,无构建)、CSS 自定义属性、Shadow DOM、pytest(静态扫描)、Pillow(素材压缩)。
**Spec:** [docs/superpowers/specs/2026-08-31-dynamic-theme-color-design.md](../specs/2026-08-31-dynamic-theme-color-design.md)
**三处实施细化(相对 spec,收尾任务会回写 spec):**
1. §5 borderSoft 规则调参:`s×0.75, l=82` → **`s×0.72, l=84`**(原参数输出与现状蓝通道差 12,超出 ≤8 容差;调后偏差 (0,2,2))
2. §12.4 遮盖机制:素材是透明底 PNG,`<img>` 无法"遮盖"emoji——改为 **load 成功后隐藏 emoji 兄弟节点**,error 时移除 img 露出 emoji(兜底语义不变)
3. §12.2"单一常量"落在挂件侧(widget.js `DEFAULT_AVATAR_PATH`,跨域场景所在);chat.html 为同源直接字面量,不跨文件共享
**数值纪律:** `DEFAULT_PALETTE` 与 style.css `:root` 的默认字面量必须以算法实跑输出为准(已用 Python 复刻 JS `Math.round` 半进位舍入验证——手算会差 1),不得手改。
**约定(全计划适用):**
- 每个任务结束跑 `python -m pytest tests/ -x -q` 确认无回归
- commit 信息用中文,与仓库风格一致
- 派生函数体内**不得出现字符串字面量含 `{`/`}`**(pytest 用花括号计数提取函数),两份拷贝的函数体(含注释)必须逐字符一致(空白归一化后)
---
### Task 1: pytest 契约测试(theme.js 存在性与导出)
**Files:**
- Create: `tests/test_theme_static.py`
- [ ] **Step 1: 写失败测试**
```python
"""动态主题色静态回归网(spec §8.1、§12.7)。"""
import re
from pathlib import Path
STATIC = Path(__file__).resolve().parent.parent / "static"
SLOTS = [
"--w-primary", "--w-primary-rgb", "--w-gradient-end", "--w-deep-text",
"--w-caption-text", "--w-soft-bg", "--w-border-soft",
]
# 现状全部橙色系字面量(spec §4 表格逆向)
ORANGE = re.compile(
r"#(f08a31|F08A34|EC8C2F|f59e0b|8C4D1D|C16C2E|A8591F|d97706"
r"|F0D5BF|EECFB6|E8C4A9|F1CCAE|fef3c7"
r"|FFF8F3|fef9f3|FFF9F4|FFF7F1|faf7f3)\b",
re.IGNORECASE,
)
STRAY_RGBA = re.compile(r"rgba?\(\s*(240,\s*138,\s*49|196,\s*91,\s*25)")
def _read(rel):
return (STATIC / rel).read_text(encoding="utf-8")
def test_theme_js_exists_and_exports():
src = _read("js/theme.js")
for token in ("derivePalette", "DEFAULT_COLOR", "DEFAULT_PALETTE"):
assert token in src, f"theme.js 缺少 {token}"
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_theme_static.py -v`
Expected: FAIL(`js/theme.js` 不存在,读取报错)
---
### Task 2: 实现 theme.js
**Files:**
- Create: `static/js/theme.js`
- [ ] **Step 1: 写完整实现(逐字拷贝此代码;widget.js 内联时同一份函数体)**
```javascript
/* 客服主题派生——单品牌色 → 7 槽位色板(spec 2026-08-31 §5)。
* 注意:此文件与 static/widget.js 内联拷贝必须逐字符一致(空白归一化后),
* 由 tests/test_theme_static.py 强制;修改务必两边同步。 */
(function (global) {
"use strict";
var DEFAULT_COLOR = "#f08a31";
var DEFAULT_PALETTE = {
primary: "#f08a31",
primaryRgb: "240,138,49",
gradientEnd: "#f08e2c",
deepText: "#8a4c1d",
captionText: "#c16d2d",
softBg: "#fef8f4",
borderSoft: "#f0d3bd"
};
function clampNum(v, lo, hi) {
return Math.min(hi, Math.max(lo, v));
}
function hexToRgb(hex) {
hex = String(hex || "").replace(/^#/, "");
if (hex.length === 3) {
hex = hex.split("").map(function (ch) { return ch + ch; }).join("");
}
if (!/^[0-9a-fA-F]{6}$/.test(hex)) return null;
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16)
};
}
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
var max = Math.max(r, g, b), min = Math.min(r, g, b);
var l = (max + min) / 2;
var d = max - min;
var h = 0, s = 0;
if (d !== 0) {
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
if (h < 0) h += 360;
}
return { h: h, s: s * 100, l: l * 100 };
}
function toHex2(v) {
var s = Math.round(clampNum(v, 0, 255)).toString(16);
return s.length < 2 ? "0" + s : s;
}
function hslToHex(h, s, l) {
h = ((h % 360) + 360) % 360;
s = clampNum(s, 0, 100) / 100;
l = clampNum(l, 0, 100) / 100;
var c = (1 - Math.abs(2 * l - 1)) * s;
var x = c * (1 - Math.abs(((h / 60) % 2) - 1));
var m = l - c / 2;
var rp = 0, gp = 0, bp = 0;
if (h < 60) { rp = c; gp = x; }
else if (h < 120) { rp = x; gp = c; }
else if (h < 180) { gp = c; bp = x; }
else if (h < 240) { gp = x; bp = c; }
else if (h < 300) { rp = x; bp = c; }
else { rp = c; bp = x; }
return "#" + toHex2((rp + m) * 255) + toHex2((gp + m) * 255) + toHex2((bp + m) * 255);
}
function derivePalette(color) {
try {
var rgb = hexToRgb(color);
if (!rgb) {
if (typeof console !== "undefined" && console.warn) console.warn("[chat-theme] invalid color, fallback:", color);
return DEFAULT_PALETTE;
}
var hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
var h = hsl.h, s = hsl.s, l = hsl.l;
return {
primary: "#" + toHex2(rgb.r) + toHex2(rgb.g) + toHex2(rgb.b),
primaryRgb: rgb.r + "," + rgb.g + "," + rgb.b,
gradientEnd: hslToHex(h + 2, s, l - 1),
deepText: hslToHex(h - 2, s * 0.76, clampNum(l - 24, 28, 40)),
captionText: hslToHex(h - 2, s * 0.72, clampNum(l - 10, 40, 52)),
softBg: hslToHex(h, s * 0.9, 97.5),
borderSoft: hslToHex(h - 2, s * 0.72, 84)
};
} catch (e) {
return DEFAULT_PALETTE;
}
}
global.ChatTheme = {
derivePalette: derivePalette,
DEFAULT_COLOR: DEFAULT_COLOR,
DEFAULT_PALETTE: DEFAULT_PALETTE
};
})(typeof window !== "undefined" ? window : this);
```
- [ ] **Step 2: 跑 Task 1 测试确认通过**
Run: `python -m pytest tests/test_theme_static.py -v`
Expected: PASS
- [ ] **Step 3: Commit**
```bash
git add static/js/theme.js tests/test_theme_static.py
git commit -m "feat(theme): 派生纯函数 theme.js——单色HSL派生7槽位+4层兜底"
```
---
### Task 3: 生成压缩头像素材
**Files:**
- Create: `static/kefu-small.png`(从 `static/kefu.png` 派生,原图不动)
- [ ] **Step 1: 用 Pillow 生成(168px 长边,保留透明)**
```bash
python -c "
from PIL import Image
im = Image.open('static/kefu.png').convert('RGBA')
im.thumbnail((168, 168), Image.LANCZOS)
im.save('static/kefu-small.png', optimize=True)
print(im.size)
"
```
Expected: 输出尺寸 ≤(168,168);文件显著小于 682KB(通常 <60KB)
- [ ] **Step 2: Commit**
```bash
git add static/kefu-small.png
git commit -m "feat(avatar): 压缩头像素材 kefu-small.png——168px显示级,原图保留"
```
---
### Task 4: pytest 静态扫描网(槽位/橙残留/同步/avatar)
> **2026-08-31 审查加固(提交 9b82949,以下代码块为初版,以实际提交的测试文件为准):**
> ① 同步检查扩展为 `SYNCED_FUNCTIONS`(6 个函数逐一比对)并新增 `test_widget_and_theme_defaults_identical`(DEFAULT_PALETTE 归一化比对 + DEFAULT_COLOR 精确比对);② `test_style_no_stray_orange` 增加负断言 `--user-bubble`/`--primary-dark` 不得存在,`test_widget_no_stray_orange` 增加 `c.primary` 不得存在(堵"删定义留使用"逃生口);③ 失败信息增强(残留样本、首个差异位置)。**红状态相应变为 7 失败/2 通过**(新增 defaults 红)。
**Files:**
- Modify: `tests/test_theme_static.py`(追加)
- [ ] **Step 1: 追加失败测试(对现状 widget.js/style.css 全红,即安全网)**
```python
def _extract_function(src, name):
"""花括号计数提取函数源码(要求函数体内字符串字面量不含大括号)。"""
start = src.index("function " + name + "(")
i = src.index("{", start)
depth = 0
for j in range(i, len(src)):
if src[j] == "{":
depth += 1
elif src[j] == "}":
depth -= 1
if depth == 0:
return src[start:j + 1]
raise AssertionError("unbalanced braces in " + name)
def test_widget_and_theme_derive_identical():
a = _extract_function(_read("js/theme.js"), "derivePalette")
b = _extract_function(_read("widget.js"), "derivePalette")
norm = lambda s: re.sub(r"\s+", "", s)
assert norm(a) == norm(b), "两份 derivePalette 漂移"
def test_widget_host_injection_complete():
src = _read("widget.js")
for slot in SLOTS:
assert slot + ":" in src, f"widget.js :host 注入缺 {slot}"
def test_style_root_defines_all_slots():
src = _read("css/style.css")
for slot in SLOTS:
assert re.search(re.escape(slot) + r"\s*:", src), f"style.css :root 缺 {slot}"
def test_var_refs_within_known_slots():
for rel in ("widget.js", "css/style.css"):
used = set(re.findall(r"var\((--w-[a-z-]+)", _read(rel)))
unknown = used - set(SLOTS)
assert not unknown, f"{rel} 引用未定义槽位: {unknown}"
def _strip_defaults(src):
m = re.search(r"var DEFAULT_PALETTE = \{.*?\};", src, re.S)
assert m, "DEFAULT_PALETTE 块不存在"
src = src.replace(m.group(0), "")
return re.sub(r'var DEFAULT_COLOR = "[^"]*"', "", src)
def test_widget_no_stray_orange():
src = _strip_defaults(_read("widget.js"))
assert not ORANGE.findall(src), "widget.js 残留橙色 hex"
assert not STRAY_RGBA.findall(src), "widget.js 残留橙色 rgba"
def test_style_no_stray_orange():
bad = [
ln for ln in _read("css/style.css").splitlines()
if (ORANGE.search(ln) or STRAY_RGBA.search(ln)) and "--w-" not in ln
]
assert not bad, "style.css 残留橙色(非 :root 槽位行): " + "; ".join(bad[:5])
def test_widget_avatar_src_uses_wsbase():
src = _read("widget.js")
assert 'DEFAULT_AVATAR_PATH = "/static/kefu-small.png"' in src
assert 'src="/static/kefu-small.png"' not in src, "挂件禁用相对路径头像(客户站会 404)"
assert "wsBase + DEFAULT_AVATAR_PATH" in src
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_theme_static.py -v`
Expected: Task 1 的测试仍 PASS;`test_var_refs_within_known_slots` 也 PASS(现状无任何 `var(--w-` 引用,空集通过——安全网在 Task 5/6 引入引用后生效);**其余 7 项 FAIL**(现状文件未改造):`derive_identical`、`defaults_identical`、`host_injection_complete`、`style_root_defines_all_slots`、`style_no_stray_orange`、`widget_no_stray_orange`、`avatar_src_uses_wsbase`
- [ ] **Step 3: Commit**
```bash
git add tests/test_theme_static.py
git commit -m "test(theme): 静态扫描安全网——槽位完整性/橙残留/双份同步/avatar跨域"
```
---
### Task 5: style.css 槽位化
**Files:**
- Modify: `static/css/style.css`
- [ ] **Step 1: 重写 `:root`(第 7-20 行区域)**
删除 `--primary`、`--primary-dark`、`--user-bubble`(死代码/迁移),改为:
```css
:root {
--w-primary: #f08a31;
--w-primary-rgb: 240, 138, 49;
--w-gradient-end: #f08e2c;
--w-deep-text: #8a4c1d;
--w-caption-text: #c16d2d;
--w-soft-bg: #fef8f4;
--w-border-soft: #f0d3bd;
--bg: #f5f5f5;
--chat-bg: #ffffff;
--ai-bubble: #f0f0f0;
--text: #333333;
--text-light: #666666;
--text-white: #ffffff;
--border: #e0e0e0;
--success: #34a853;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
```
- [ ] **Step 2: 按映射表全量替换(先 Read 文件,再逐项 Edit;每项在文件中唯一或用 replace_all)**
| 旧字面量 | 新值 |
|---|---|
| `background: #f08a31;`(4 处:L45/L155/L220/L395) | `background: var(--w-primary);` |
| `border: 1px solid #fef3c7;`(L329,帮助块) | `border: 1px solid var(--w-border-soft);` |
| `border: 3px solid #f08a31;` | `border: 3px solid var(--w-primary);` |
| `var(--user-bubble)`(消息气泡) | `var(--w-primary)` |
| `rgba(240, 138, 49, X)`(全部 α 变体) | `rgba(var(--w-primary-rgb), X)` |
| `linear-gradient(135deg, #f08a31, #f59e0b)` | `linear-gradient(135deg, var(--w-primary), var(--w-gradient-end))` |
| `background: #fef9f3;` / `#faf7f3;` / `#FFF8F3;` | `background: var(--w-soft-bg);` |
| `color: #8C4D1D;` | `color: var(--w-deep-text);` |
| `color: #C16C2E;` / `#d97706;` | `color: var(--w-caption-text);` |
| `border: 1px solid #F0D5BF;` | `border: 1px solid var(--w-border-soft);` |
替换后自检:`grep -nE "f08a31|f59e0b|8C4D1D|C16C2E|d97706|F0D5BF|FFF8F3|fef9f3|faf7f3|240, ?138" static/css/style.css` 应只剩 `:root` 槽位行。
- [ ] **Step 3: 跑扫描网确认 style.css 侧转绿**
Run: `python -m pytest tests/test_theme_static.py -v`
Expected: `test_style_root_defines_all_slots`、`test_style_no_stray_orange` PASS;widget.js 相关仍 FAIL(后续任务)
- [ ] **Step 4: Commit**
```bash
git add static/css/style.css
git commit -m "feat(theme): style.css 槽位化——:root 7槽位默认值,清除全部硬编码橙"
```
---
### Task 6: widget.js 派生注入 + CSS 全量槽位化
**Files:**
- Modify: `static/widget.js`
- [ ] **Step 1: 属性读取区(L8-16 之后)加入 data-color/data-avatar/data-debug 读取与内联派生**
在 `var wsBase = ...;`(L16)后追加(派生函数体**逐字拷贝** theme.js 的 clampNum/hexToRgb/rgbToHsl/toHex2/hslToHex/derivePalette 六个函数与 DEFAULT_COLOR/DEFAULT_PALETTE 常量,含相同注释):
```javascript
var DEFAULT_AVATAR_PATH = "/static/kefu-small.png";
var avatarAttr = script.getAttribute("data-avatar");
var avatarUrl = avatarAttr
? (/^https?:\/\//i.test(avatarAttr) ? avatarAttr : wsBase + "/" + avatarAttr.replace(/^\//, ""))
: wsBase + DEFAULT_AVATAR_PATH;
var palette = derivePalette(script.getAttribute("data-color") || DEFAULT_COLOR);
if (script.getAttribute("data-debug") === "true") {
window.__chatWidgetDebug = { derivePalette: derivePalette, palette: palette };
}
```
同时**删除** L31-36 的旧 `colors`/`c` 对象中 `primary` 的使用点(见 Step 3 的 c.primary 替换;`c.bg`/`c.bubble` 保留给明暗模式)。
- [ ] **Step 2: style 数组头部插入 :host 注入块(作为第一个数组元素)**
```javascript
":host{--w-primary:" + palette.primary + ";--w-primary-rgb:" + palette.primaryRgb + ";--w-gradient-end:" + palette.gradientEnd + ";--w-deep-text:" + palette.deepText + ";--w-caption-text:" + palette.captionText + ";--w-soft-bg:" + palette.softBg + ";--w-border-soft:" + palette.borderSoft + ";}",
```
- [ ] **Step 3: 按映射表替换 style 字符串全部橙色(先 Read widget.js L49-170)**
| 行(现状) | 旧 → 新 |
|---|---|
| L55 | `background:#f08a31` → `background:var(--w-primary)`;`rgba(240,138,49,0.4)` → `rgba(var(--w-primary-rgb),0.4)` |
| L59 | 同上(`0.45` 阴影同理) |
| L60 | `border:2px solid #f08a31` → `var(--w-primary)` |
| L61 | `color:#8C4D1D` → `var(--w-deep-text)` |
| L67 | `background:" + c.bg + "` 保留 |
| L71 | `background:#f08a31` → `var(--w-primary)` |
| L73 | `border:3px solid #f08a31` → `var(--w-primary)` |
| L82 | `#faf7f3` → `var(--w-soft-bg)` |
| L85 | `#f08a31` → `var(--w-primary)` |
| L86 | `#fef9f3`→`var(--w-soft-bg)`;`#fef3c7`→`var(--w-border-soft)` |
| L91 | `#8C4D1D`→`var(--w-deep-text)`;`#F0D5BF`→`var(--w-border-soft)` |
| L95 | `#C16C2E` → `var(--w-caption-text)` |
| L97 | `#F0D5BF`→`var(--w-border-soft)`;`#FFF8F3`→`var(--w-soft-bg)`;`#8C4D1D`→`var(--w-deep-text)` |
| L100 | `#f08a31`→`var(--w-primary)`;`rgba(240,138,49,0.3)`→`rgba(var(--w-primary-rgb),0.3)` |
| L102 | `c.primary` → `var(--w-primary)` |
| L106 | `#f08a31` → `var(--w-primary)` |
| L109 | `#d97706` → `var(--w-caption-text)` |
| L122 | `#fef9f3` → `var(--w-soft-bg)` |
| L126 | 渐变→`linear-gradient(135deg, var(--w-primary), var(--w-gradient-end))`;阴影 `0.25` 同理 |
| L135 | `#F1CCAE`→`var(--w-border-soft)`;`rgba(196,91,25,0.22)`→`rgba(var(--w-primary-rgb),0.22)` |
| L140 | 渐变→`linear-gradient(135deg, var(--w-primary), var(--w-gradient-end))` |
| L143 | `#F08A34` → `var(--w-primary)` |
| L150-151 | `#8C4D1D`→`var(--w-deep-text)`;`#F0D5BF`→`var(--w-border-soft)`;`#FFF8F3`→`var(--w-soft-bg)` |
| L154 | `#FFF9F4` → `var(--w-soft-bg)` |
| L158-159 | `#EECFB6`→`var(--w-border-soft)`;`#8C4D1D`→`var(--w-deep-text)`;`#FFF7F1`→`var(--w-soft-bg)` |
| L163 | 渐变+阴影同 L126 |
| L165 | `#E8C4A9`→`var(--w-border-soft)`;`#A8591F`→`var(--w-deep-text)` |
| L166 | `#FEFEFE` 保留(中性色) |
自检:`grep -nE "f08a31|F08A34|EC8C2F|f59e0b|8C4D1D|C16C2E|A8591F|d97706|F0D5BF|EECFB6|E8C4A9|F1CCAE|fef3c7|FFF8F3|fef9f3|FFF9F4|FFF7F1|faf7f3|240,138|196,91" static/widget.js` 应只剩:DEFAULT_COLOR/DEFAULT_PALETTE 块内 + **4 处 SVG stroke `#8C4D1D`(L189×2/L219/L223)+ toggle 模板 inline `#f08a31`(L268)——这 5 处属 Task 7 范围**。
- [ ] **Step 4: 跑扫描网**
Run: `python -m pytest tests/test_theme_static.py -v`
Expected: `test_widget_and_theme_derive_identical`、`test_widget_and_theme_defaults_identical`、`test_widget_host_injection_complete`、`test_var_refs_within_known_slots`、`test_widget_avatar_src_uses_wsbase` PASS;**`test_widget_no_stray_orange` 仍 FAIL**(残留即上述 5 处 Task 7 项——grep 输出会精确指向它们);style.css 侧两项已在 Task 5 转绿
- [ ] **Step 5: Commit**
```bash
git add static/widget.js
git commit -m "feat(theme): widget.js 槽位化——内联派生+:host注入+40处橙色换var()"
```
---
### Task 7: widget.js SVG currentColor + toggle 内联样式类化
**Files:**
- Modify: `static/widget.js`
- [ ] **Step 1: SVG 描边改 currentColor(4 处 stroke:L189 两处、L219、L223)**
`stroke="#8C4D1D"` → `stroke="currentColor"`(父级 .call-btn/.invite-action-btn 已有对应 color)
- [ ] **Step 2: 桌面 toggle 按钮 inline 样式类化(为头像与定位上下文铺路)**
L265-274 模板改为:
```javascript
toggleBtn.innerHTML = `
<div class="toggle-avatar-wrap">
<div class="toggle-avatar-circle"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="${avatarUrl}"></div>
<div class="toggle-avatar-dot"></div>
</div>
<div style="color:white;">
<div style="font-size:14px;font-weight:600;">Chat with us</div>
<div style="font-size:11px;opacity:0.9;">Usually replies quickly</div>
</div>
`;
```
style 数组追加(内圆显式 `position:relative`,spec §12.4 复审修正):
```css
.toggle-avatar-wrap { position:relative; }
.toggle-avatar-circle { position:relative; width:36px; height:36px; background:rgba(255,255,255,0.2); border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:18px; }
.toggle-avatar-dot { position:absolute; top:0; right:0; width:10px; height:10px; background:#22c55e; border-radius:50%; border:2px solid var(--w-primary); }
```
- [ ] **Step 3: 跑扫描网确认全绿**
Run: `python -m pytest tests/test_theme_static.py -v`
Expected: **全部 PASS**(`test_widget_no_stray_orange` 在本任务转绿——最后 5 处橙色已清除)
- [ ] **Step 4: Commit**
```bash
git add static/widget.js
git commit -m "refactor(widget): SVG改currentColor+toggle内联样式类化(含定位上下文)"
```
---
### Task 8: widget.js 头像替换(4 处)+ 兜底监听
**Files:**
- Modify: `static/widget.js`
- [ ] **Step 1: style 数组追加头像通用规则**
```css
.avatar-emoji { line-height:1; }
.avatar-img { position:absolute; inset:0; width:100%; height:100%; object-fit:contain; border-radius:50%; }
```
- [ ] **Step 2: 三处模板替换(桌面 toggle 已在 Task 7 完成)**
- L181 `<div class="avatar">🤖</div>` → `<div class="avatar"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="'+avatarUrl+'"></div>`(面板模板为普通字符串拼接)
- L209 `<div class="invite-icon">🤖<span class="invite-status-dot"></span></div>` → `<div class="invite-icon"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="'+avatarUrl+'"><span class="invite-status-dot"></span></div>`(同为普通拼接)
- L261 mobile 模板 `<div class="mobile-circle">🤖<span class="mobile-dot"></span></div>` → `<div class="mobile-circle"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="${avatarUrl}"><span class="mobile-dot"></span></div>`(反引号模板,用 `${}`)
注意:L179-202 面板模板是普通字符串拼接(`'...' + title + '...'`),用 `'+avatarUrl+'` 形式;L205+ 邀请卡同为普通串;仅 toggle 是反引号模板。
- [ ] **Step 3: 兜底监听(container/inviteEl appendChild 之后、事件绑定区之前)**
```javascript
// 头像兜底:load 成功隐藏底层 emoji;error 移除 img 露出 emoji(spec §12.4)
Array.prototype.forEach.call(shadow.querySelectorAll(".avatar-img"), function (img) {
function hideEmoji() {
var s = img.parentElement && img.parentElement.querySelector(".avatar-emoji");
if (s) s.style.display = "none";
}
if (img.complete && img.naturalWidth > 0) { hideEmoji(); return; }
img.addEventListener("load", hideEmoji);
img.addEventListener("error", function () { img.remove(); });
});
```
- [ ] **Step 4: 跑扫描网全绿确认**
Run: `python -m pytest tests/test_theme_static.py -v`
Expected: 全部 PASS(含 `test_widget_avatar_src_uses_wsbase`)
- [ ] **Step 5: Commit**
```bash
git add static/widget.js
git commit -m "feat(avatar): 挂件4处emoji换图片头像——绝对定位+emoji兜底+data-avatar可配"
```
---
### Task 9: 独立页接入(chat.html + chat.js)
**Files:**
- Modify: `static/chat.html`、`static/js/chat.js`
- [ ] **Step 1: chat.html——L7 stylesheet 后加 theme.js,L12 头像替换,L36/L40 SVG,L50 死注释删除**
```html
<link rel="stylesheet" href="/static/css/style.css">
<script src="/static/js/theme.js"></script>
```
L12:`<div class="avatar">🤖</div>` → `<div class="avatar"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="/static/kefu-small.png"></div>`
L36/L40:`stroke="#8C4D1D"` → `stroke="currentColor"`
L50:删除整行(`<!-- <div class="avatar">🤖</div> -->`)
- [ ] **Step 2: chat.js——文件顶部追加 bootstrap(在现有逻辑之前)**
```javascript
// ---- 动态主题色 bootstrap(spec §3):?color= → 派生 → :root 变量 ----
(function () {
if (typeof ChatTheme === "undefined") return; // theme.js 加载失败→style.css :root 默认橙兜底
var m = /(?:^|[?&])color=([^&]*)/.exec(location.search);
var palette = ChatTheme.derivePalette(m ? decodeURIComponent(m[1]) : ChatTheme.DEFAULT_COLOR);
var root = document.documentElement.style;
root.setProperty("--w-primary", palette.primary);
root.setProperty("--w-primary-rgb", palette.primaryRgb);
root.setProperty("--w-gradient-end", palette.gradientEnd);
root.setProperty("--w-deep-text", palette.deepText);
root.setProperty("--w-caption-text", palette.captionText);
root.setProperty("--w-soft-bg", palette.softBg);
root.setProperty("--w-border-soft", palette.borderSoft);
// 头像兜底(与挂件同机制)
Array.prototype.forEach.call(document.querySelectorAll(".avatar-img"), function (img) {
function hideEmoji() {
var s = img.parentElement && img.parentElement.querySelector(".avatar-emoji");
if (s) s.style.display = "none";
}
if (img.complete && img.naturalWidth > 0) { hideEmoji(); return; }
img.addEventListener("load", hideEmoji);
img.addEventListener("error", function () { img.remove(); });
});
})();
```
- [ ] **Step 3: style.css 追加头像规则(若 Task 5 未含)**
```css
.avatar-emoji { line-height: 1; }
.avatar-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; border-radius: 50%; }
```
- [ ] **Step 4: 全量 pytest + Commit**
Run: `python -m pytest tests/ -q` → Expected: 全 PASS
```bash
git add static/chat.html static/js/chat.js static/css/style.css
git commit -m "feat(theme): 独立页接入——?color=参数+theme.js+头像图片+SVG currentColor"
```
---
### Task 10: widget-test.html 重写(预览 + 断言面板)
**Files:**
- Modify: `static/widget-test.html`(整页重写)
- [ ] **Step 1: 重写页面——保留原说明,新增控制区与断言区**
结构:原 info 卡片保留;`<head>` 中**必须**加 `<script src="/static/js/theme.js"></script>`(断言面板依赖 `window.ChatTheme`,漏掉则面板永远显示钩子未就绪);新增:
```html
<div class="info">
<h2>Theme Color Preview</h2>
<p><input type="color" id="colorPicker" value="#f08a31">
<button onclick="applyColor()">Apply</button>
<button onclick="applyColor('#f08a31')">Orange</button>
<button onclick="applyColor('#1a73e8')">Blue</button>
<button onclick="applyColor('#16a34a')">Green</button>
<button onclick="applyColor('#7c3aed')">Purple</button>
<button onclick="applyBrokenAvatar()">Test Broken Avatar</button></p>
</div>
<div class="info">
<h2>Assertion Panel</h2>
<div id="assertions"></div>
</div>
```
- [ ] **Step 2: teardown/重建逻辑(spec §7.1 陷阱;基础属性对象 + extra 合并,缓存戳防缓存)**
```javascript
var BASE_ATTRS = {
"data-position": "bottom-right",
"data-theme": "light",
"data-mobile": "true",
"data-debug": "true",
"data-whatsapp": "https://wa.me/8613800138000",
"data-email": "mailto:demo@hesu-auto.com"
};
function rebuildWidget(extra) {
var host = document.getElementById("cc-widget-host");
if (host) host.remove();
document.querySelectorAll("script[src*='widget.js']").forEach(function (s) { s.remove(); });
try { sessionStorage.removeItem("widget_invite_dismissed_" + window.location.pathname); } catch (e) {}
var s = document.createElement("script");
s.src = "/static/widget.js?" + Date.now();
var attrs = Object.assign({}, BASE_ATTRS, extra || {});
Object.keys(attrs).forEach(function (k) { s.setAttribute(k, attrs[k]); });
s.onload = runAssertions;
document.body.appendChild(s);
}
function applyColor(c) {
rebuildWidget({ "data-color": c || document.getElementById("colorPicker").value });
}
function applyBrokenAvatar() {
rebuildWidget({ "data-color": "#1a73e8", "data-avatar": "/static/nope.png" });
}
```
- [ ] **Step 3: 断言面板(spec §8.2;页面加载后自动跑一次)**
```javascript
var CURRENT_HARDCODED = { // 现状代表值(spec §4),Δ ≤ 8/通道
primary: "#f08a31", gradientEnd: "#F08A34", deepText: "#8C4D1D",
captionText: "#C16C2E", softBg: "#FFF8F3", borderSoft: "#F0D5BF"
};
function hexToRgb6(h) { /* 复用 theme.js 的 hexToRgb 逻辑或简化实现 */ }
function channelDelta(a, b) { /* 返回 max 通道差 */ }
function runAssertions() {
var rows = [];
function check(name, ok, detail) { rows.push((ok ? "✅ " : "❌ ") + name + (detail ? " — " + detail : "")); }
var t = window.ChatTheme, d = window.__chatWidgetDebug;
if (!t || !d) { rows.push("❌ theme.js / widget debug 钩子未就绪"); }
else {
var p = t.derivePalette("#f08a31");
Object.keys(CURRENT_HARDCODED).forEach(function (k) {
var delta = channelDelta(p[k], CURRENT_HARDCODED[k]);
check("default " + k + " Δ≤8 (" + p[k] + " vs " + CURRENT_HARDCODED[k] + ")", delta <= 8, "Δ=" + delta);
});
check("derive(default) === DEFAULT_PALETTE",
JSON.stringify(p) === JSON.stringify(t.DEFAULT_PALETTE));
check("inline copy === theme.js copy",
d.derivePalette.toString().replace(/\s+/g, "") === t.derivePalette.toString().replace(/\s+/g, ""));
check("invalid color → DEFAULT_PALETTE",
JSON.stringify(t.derivePalette("not-a-color")) === JSON.stringify(t.DEFAULT_PALETTE));
}
document.getElementById("assertions").innerHTML = rows.map(function (r) { return "<div>" + r + "</div>"; }).join("");
}
```
页面初始嵌入(替代原 L69 的静态 script 标签)同样带 `data-debug="true"` 并挂 `onload="runAssertions()"`,属性与 BASE_ATTRS 一致。
- [ ] **Step 4: 手动验收(浏览器,无自动化,如实记录结果)**
1. 打开 `/static/widget-test.html`:断言面板全 ✅
2. 默认橙:邀请卡(1.8s)+ 聊天面板 + 头像 5 处中挂件侧 4 处,与改造前截图对比无可见差异
3. Blue/Green/Purple:全套配色跟随,无橙色残留
4. Test Broken Avatar:头像回退 🤖
5. `/`(独立页)与 `/?color=1a73e8`:独立页跟随;头像替换;SVG 图标为深主题色
6. `?color=red`(非法):回退默认橙,console 有 warn
- [ ] **Step 5: Commit**
```bash
git add static/widget-test.html
git commit -m "feat(test): widget-test重写——换色预览+teardown重建+浏览器断言面板"
```
---
### Task 11: changelog 文档 + spec 校准 + 终验
**Files:**
- Create: `docs/2026-08-31-dynamic-theme-changelog.md`
- Modify: `docs/superpowers/specs/2026-08-31-dynamic-theme-color-design.md`(三处校准)
- [ ] **Step 1: 写 changelog(按仓库惯例,含嵌入方用法)**
```markdown
# 2026-08-31 动态主题色 + 头像图片化
## 嵌入方用法
- 品牌色:`<script src=".../widget.js" data-color="#1a73e8">`(3/6 位 hex,缺省=默认橙 #f08a31)
- 自定义头像:`data-avatar="https://.../logo.png"`(完整 URL)或 `data-avatar="/static/xxx.png"`(本服务路径);缺省=内置客服头像
- 独立页:`/?color=1a73e8`
- 已知限制:极浅品牌色(l>65)配白字对比度低;非法色值回退默认橙并 console.warn
## 实现要点
- 单色 HSL 派生 7 语义槽位,CSS 变量注入(:host / :root)
- 4 层兜底:缺失→默认 / 非法→warn+默认 / 派生异常→静态色板 / 结构性(:root 字面量)
- 头像:绝对定位覆盖 + load 隐藏 emoji / error 移除回退 emoji
- 回归网:tests/test_theme_static.py(槽位完整/橙残留/双份同步/跨域头像)
```
- [ ] **Step 2: spec 三处校准(与实施一致)**
- §5 表 borderSoft 行:`h-2, s×0.75, l=82` → `h-2, s×0.72, l=84`(容差内)
- §12.4:遮盖机制改为 "load 成功后隐藏 emoji 兄弟节点;error 移除 img(透明底素材无法物理遮盖)"
- §12.2:"单一常量"限定为挂件侧 `DEFAULT_AVATAR_PATH`(跨域场景所在);chat.html 为同源直接字面量,不跨文件共享
- [ ] **Step 3: 终验**
Run: `python -m pytest tests/ -q` → Expected: 全 PASS
Run: Task 10 Step 4 手动清单全部通过(若未做,补做)
- [ ] **Step 4: Commit**
```bash
git add docs/2026-08-31-dynamic-theme-changelog.md docs/superpowers/specs/2026-08-31-dynamic-theme-color-design.md
git commit -m "docs: 动态主题色changelog——嵌入方用法+spec三处实施校准"
```
@@ -0,0 +1,954 @@
# 修复"客户已留资后 AI 仍重复索要联系方式"实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 客户提供 WhatsApp 号码/邮箱后,AI 在任何后续轮次(含留资当轮)都不再索要联系方式——堵住捕获缺口、消除同轮竞态、给三条确定性索要通路加"已留资"门控。
**Architecture:** 三层修复:①数据层——当前消息即时正则扫描(不落库,仅供当轮提醒/路由)+ 正则形态扩充 + key 模糊归一,使"已留资"状态尽早尽全地可见;②提醒/过滤层——App 索要脚本与零号码墙替换句按已留资状态路由;③prompt 层——"假称已留过号"应对改为先查记录。持久化管道(轮末 extractor+scanner+recency)语义零改动。
**Tech Stack:** Python 3.12 / FastAPI / SQLAlchemy async / pytest(纯函数单测 + async_db 集成测),无新依赖、无 schema、无前端、无配置。
**问题分析档案:** 本次会话分析报告(根因 1-6 与因果链),实施时如需背景可读 `docs/2026-08-20-company-contact-handoff-changelog.md`(本计划是其 §6"已知限制"中两残余项的收敛)。
---
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `app/services/conversation.py` | 修改 | Task 1 同轮即时扫描 / Task 4 App 提醒门控 / Task 5 墙路由 / Task 6 触发词收窄 / Task 8 提醒逐字化 |
| `app/services/contact_pipeline.py` | 修改 | Task 2 正则形态扩充 / Task 3 key 模糊归一 |
| `app/services/boundary_filter.py` | 修改 | Task 5 新增 `NO_CONTACT_ALREADY` 常量 |
| `app/prompts/system.md` | 修改 | Task 7 "假称已留过号"先查记录 |
| `tests/test_contact_pipeline.py` | 修改 | Task 2/3 单测 |
| `tests/test_lead_email_trigger.py` | 修改 | Task 1/4/6/8 单测与集成测 |
| `tests/test_boundary_filter.py` | 修改 | Task 5 单测 |
| `docs/2026-09-08-stop-reasking-contact-changelog.md` | 新建 | Task 10 改动文档(仓库惯例) |
不动的文件:`app/services/ai_service.py`、`app/providers/glm.py`、`app/prompts/extractor.md`、`app/prompts/evaluator.md`、前端全部。
---
### Task 1: 当前消息即时扫描(同轮竞态消除,P0 核心)
**原理:** 现在提醒与 `has_contact` 用的是上一轮画像([conversation.py:149-151](app/services/conversation.py#L149-L151)、[conversation.py:165-166](app/services/conversation.py#L165-L166)),客户留资的**当轮**完全不受保护。本任务在构建提醒前对当前 `content` 跑一次纯正则扫描(毫秒级、零 LLM),结果只并入**本轮内存视图**,不落库——持久化仍由轮末管道负责,邮件触发时序零变化。
**Files:**
- Modify: `app/services/conversation.py:146-166`
- Test: `tests/test_lead_email_trigger.py`
- [ ] **Step 1: 写失败测试(同轮提醒含"已提供")**
在 `tests/test_lead_email_trigger.py` 的 `test_reminder_says_no_reasking_when_contact_present` 附近追加:
```python
def test_same_turn_scan_feeds_reminder(async_db, monkeypatch):
# P0 同轮竞态: 客户当轮才发号码(画像仍空),提醒就必须已含"已提供+禁止再索要"
(manager, conv), _ = _setup(async_db, monkeypatch, ai=FakeAI(extract={}))
captured = {}
class CaptureAI(FakeAI):
async def stream_chat(self, messages, context=""):
captured["context"] = context
async for t in super().stream_chat(messages, context):
yield t
manager.ai = CaptureAI(extract={})
asyncio.run(_send(manager, conv.session_id, "my whatsapp is +254712345678"))
assert "已提供" in captured["context"]
assert "禁止" in captured["context"]
asyncio.run(manager.db.close())
```
- [ ] **Step 2: 运行确认失败**
Run: `python -m pytest tests/test_lead_email_trigger.py::test_same_turn_scan_feeds_reminder -v`
Expected: FAIL(`captured["context"]` 不含"已提供"——画像为空走早退分支)
- [ ] **Step 3: 最小实现**
`app/services/conversation.py`——`stream_process_message` 中,将(约 149-166 行):
```python
profile = await self._get_profile(conv.id)
context_reminder = self._build_context_reminder(
profile.raw_info or {}, content, history, source=conv.source.value)
```
改为:
```python
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)
```
同函数 `has_contact`(约 165-166 行)改用同一视图:
```python
has_contact = bool(split_validate(
normalize_contacts(effective_raw.get("联系方式")))[0])
```
注意:`_enrich_short_message(content, profile.raw_info or {}, history)` 保持用原画像(该函数本来就排除联系方式,两者输出恒等;不动它以缩小爆炸半径)。
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_lead_email_trigger.py::test_same_turn_scan_feeds_reminder -v`
Expected: PASS
- [ ] **Step 5: 写第二个失败测试(同轮 has_contact 路由价格墙替换句)**
```python
def test_same_turn_contact_routes_deflection(async_db, monkeypatch):
# 同轮"号码+问价": 价格墙替换句不得再索要(走 *_CONTACT 版)
from app.services.boundary_filter import DEFLECTIONS_EN_CONTACT
class PriceAI(FakeAI):
async def stream_chat(self, messages, context=""):
for token in ("it costs ", "$25,000 ok?"):
yield token
(manager, conv), _ = _setup(
async_db, monkeypatch,
ai=PriceAI(extract={}), source=ConversationSource.standalone)
asyncio.run(_send(manager, conv.session_id,
"my whatsapp is +254712345678, how much?"))
msgs = asyncio.run(manager._load_history(conv.id))
assistant_tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1]
assert assistant_tail in DEFLECTIONS_EN_CONTACT
asyncio.run(manager.db.close())
```
- [ ] **Step 6: 运行(Step 3 已使其通过;若仍 FAIL 则排查)**
Run: `python -m pytest tests/test_lead_email_trigger.py::test_same_turn_contact_routes_deflection -v`
Expected: PASS(替换句为 "Our manager will send the exact quote to your WhatsApp shortly...")
- [ ] **Step 7: 全量回归 + 提交**
Run: `python -m pytest tests/ -q`
Expected: 全绿(基线记录法见文末"残余风险"第 7 条)
```bash
git add app/services/conversation.py tests/test_lead_email_trigger.py
git commit -m "fix(contact): 当前消息即时扫描喂提醒/has_contact——同轮留资即受保护"
```
---
### Task 2: 正则形态扩充(捕获缺口,P0)
**原理:** 根因 1 的四种漏网形态。所有新形态沿用既有"邻接绑定"原则(上下文词与数字必须在同一匹配内),且 `latest_stated_contacts` 复用同一批正则,自动获得覆盖权——这与"显式自述可覆盖"语义一致。
**Files:**
- Modify: `app/services/contact_pipeline.py:63-77`(正则区)
- Test: `tests/test_contact_pipeline.py`
- [ ] **Step 1: 写失败测试(新增四个捕获 + 三个误报防护)**
在 `tests/test_contact_pipeline.py` 末尾追加(注意:不要 import 尚未实现的符号,否则整个文件 collection 报错,Step 2 的部分 PASS 预期就没了):
```python
# ---- P0 捕获缺口扩充: 号码在前/无is/msg me/wa冒号 ----
# 全部经 scan_user_messages 断言,不直接 import 新正则符号
def test_scan_number_before_whatsapp():
# 倒装: "0712 345 678 is my whatsapp"
assert scan_user_messages(m("0712 345 678 is my whatsapp")) == {
"whatsapp": "0712 345 678"}
def test_scan_number_before_wechat_cn():
# 中文倒装(我的后无空格——正则必须用 \s* 而非 \s+,实证 \s+ 永不命中)
assert scan_user_messages(m("13800138000 我的微信")) == {
"wechat": "13800138000"}
assert scan_user_messages(m("13800138000 这是我的微信")) == {
"wechat": "13800138000"}
def test_scan_my_number_without_is():
# "my number 0712345678"(无 is/冒号)
assert scan_user_messages(m("my number 0712345678")) == {
"phone": "0712345678"}
def test_scan_text_me_forms():
assert scan_user_messages(m("text me at 07123456789")) == {
"phone": "07123456789"}
assert scan_user_messages(m("msg me 07123456789")) == {
"phone": "07123456789"}
def test_scan_wa_colon_local_number():
assert scan_user_messages(m("wa: 07123456789")) == {
"whatsapp": "07123456789"}
def test_scan_new_forms_do_not_grab_prices():
# 误报防护: 价格/单号即使与 whatsapp 词同现也不得捕获(无 my 指称/无邻接)
assert scan_user_messages(m("paid 2500000 via whatsapp transfer")) == {}
assert scan_user_messages(m("my budget 25000 number one priority")) == {}
assert scan_user_messages(m("order 1234567 is my order number")) == {}
# 排位表述(实证: 若倒装正则收 number/phone,"2500000 is my number" 被误捕,
# 且 latest_stated_contacts 覆盖权会把真实号码翻新成价格)——倒装只收显式渠道词
assert scan_user_messages(m("2500000 is my number one choice")) == {}
# wa 词界守卫: "my watch/way" 的 wa 后无词边界,不得命中
assert scan_user_messages(m("0712345678 my watch")) == {}
assert scan_user_messages(m("0712345678 my way home")) == {}
```
- [ ] **Step 2: 运行确认失败**
Run: `python -m pytest tests/test_contact_pipeline.py -k "number_before or without_is or text_me or wa_colon or new_forms" -v`
Expected: 5 个捕获测试 FAIL(返回 `{}`),防误报测试中仅排位/词界两条 FAIL(现状不捕获所以应 PASS——注意:若误报测试也全 PASS 属正常,`-k` 选中的 FAIL 集合 = 5 捕获 + 可能 0~2 防误报,以最终实现后全绿为准)
- [ ] **Step 3: 实现——扩充正则与扫描接线**
`app/services/contact_pipeline.py`:
WHATSAPP_CTX_RE 的交替组**加**裸 `wa` 冒号形态(仅冒号,防 "in WA state" 误绑)——⚠️ 保留原有 `wa\.?me` 分支,是三元交替而非替换(评审实证:漏掉它会击穿既有 `test_scan_wa_me_link`):
```python
WHATSAPP_CTX_RE = re.compile(
r"(?:whats\s*app|wa\.?me|(?:^|\b)wa\s*[::])\D{0,12}(\+?\d[\d\s\-().]{5,17}\d)",
re.IGNORECASE,
)
```
PHONE_CTX_RE 交替组加两形态(无 is 的紧邻 / msg-text-ping me):
```python
PHONE_CTX_RE = re.compile(
r"(?:my\s+(?:(?:new|other|another)\s+)?(?:phone\s+)?number\s+is|"
r"my\s+(?:(?:new|other|another)\s+)?phone\s+is|"
r"my\s+(?:(?:new|other|another)\s+)?number\s+|"
r"(?:msg|message|text|ping)\s+me\s+(?:at|on)?\s*|"
r"call\s+me\s+(?:at|on)|reach\s+me\s+(?:at|on)|"
r"phone\s*[::]|number\s*[::]|号码\s*[::]|手机号?\s*[::]|电话\s*[::])\s*"
r"(\+?\d[\d\s\-().]{5,17}\d)",
re.IGNORECASE,
)
```
(注意 `my ... number\s+` 分支要求数字紧随其后——"my number one pick" 中 "one" 非数字,天然不匹配。)
新增倒装正则与其在 `scan_user_messages`/`latest_stated_contacts` 的接线:
```python
# 倒装自述: "0712 345 678 is my whatsapp / 13800138000 我的微信"
# ⚠️ 只收显式渠道词(whatsapp/wa/wechat/微信),不收 number/phone/号码——
# 自查实证: "2500000 is my number one choice" 这类排位表述会把价格误绑成
# 号码,且本正则进入 latest_stated_contacts 即获覆盖权,误捕获会翻新掉
# 已存真实号码(正是 S1 系测试在防的事故类)。倒装电话表述罕见,不值得
# 为其承担该风险;电话主通路由 PHONE_CTX_RE 前缀形态/standalone/INTL 覆盖。
# (?:my|我的)后用 \s* 而非 \s+: 中文"我的微信"连写无空白,\s+ 永不命中(实证)。
NUMBER_FIRST_RE = re.compile(
r"(\+?\d[\d\s\-().]{5,17}\d)\s*(?:is|这是)?\s*"
r"(?:my|我的)\s*(whats\s*app|wa\b|wechat|微信)",
re.IGNORECASE,
)
_NUMBER_FIRST_CHANNEL = {
"whatsapp": "whatsapp", "whats app": "whatsapp", "wa": "whatsapp",
"wechat": "wechat", "微信": "wechat",
}
```
`scan_user_messages` 内、whatsapp 分支之后追加:
```python
if "whatsapp" not in found and "phone" not in found:
hit = NUMBER_FIRST_RE.search(text)
if hit:
ch = _NUMBER_FIRST_CHANNEL[hit.group(2).strip().lower()]
found[ch] = hit.group(1)
```
`latest_stated_contacts` 的循环内追加(与 scan 同位):
```python
hit = NUMBER_FIRST_RE.search(text)
if hit:
ch = _NUMBER_FIRST_CHANNEL[hit.group(2).strip().lower()]
latest[ch] = hit.group(1)
```
- [ ] **Step 4: 跑测试确认通过(含全文件旧测试防回归)**
Run: `python -m pytest tests/test_contact_pipeline.py -v`
Expected: 全 PASS(旧测试无一变化——扩充只增交替分支)
- [ ] **Step 5: 提交**
```bash
git add app/services/contact_pipeline.py tests/test_contact_pipeline.py
git commit -m "fix(contact): 正则补四形态——倒装/无is/my number紧邻/msg-text-ping me/wa冒号"
```
---
### Task 3: normalize_contacts key 模糊归一(extractor 变体 key 不再静默丢值)
**Files:**
- Modify: `app/services/contact_pipeline.py:13-34`
- Test: `tests/test_contact_pipeline.py`
- [ ] **Step 1: 写失败测试**
```python
def test_normalize_fuzzy_key_variants():
# extractor 常见即兴 key(精确别名表外)不得静默丢值
assert normalize_contacts({"联系电话": "13800138000"}) == {"phone": "13800138000"}
assert normalize_contacts({"手机号": "13800138000"}) == {"phone": "13800138000"}
assert normalize_contacts({"WhatsApp账号": "+254712345678"}) == {
"whatsapp": "+254712345678"}
def test_normalize_unknown_key_still_dropped():
# 完全不含渠道词的 key 仍然丢弃(如 "备注")
assert normalize_contacts({"备注": "13800138000"}) == {}
def test_normalize_slash_combined_key_deterministic():
# "电话/WhatsApp" 双渠道复合 key: 按 KEY_ALIASES 声明序取第一个命中(phone 先于 whatsapp)
assert normalize_contacts({"电话/WhatsApp": "13800138000"}) == {
"phone": "13800138000"}
```
- [ ] **Step 2: 运行确认失败**
Run: `python -m pytest tests/test_contact_pipeline.py -k fuzzy -v`
Expected: FAIL(现精确匹配返回 `{}`)
- [ ] **Step 3: 实现——精确优先,contains 兜底**
`KEY_ALIASES` 显式补入:`"联系电话": "phone", "手机号": "phone", "手机号码": "phone", "wa": "whatsapp", "wsp": "whatsapp", "whatsapp账号": "whatsapp"`(显式补齐常用形态,减少对 contains 的依赖面)。
`normalize_contacts` 改为:
```python
def normalize_contacts(raw: dict | None) -> dict:
"""key 归一化 + 去空值。精确别名优先;未命中再做 contains 兜底
(extractor 即兴变体 key 不静默丢值)。contains 按 KEY_ALIASES 声明序
取第一个命中——复合 key(电话/WhatsApp)结果确定。"""
out: dict[str, str] = {}
for k, v in (raw or {}).items():
key = str(k).strip().lower()
alias = KEY_ALIASES.get(key)
if alias is None:
for name, target in KEY_ALIASES.items():
if name in key:
alias = target
break
val = str(v or "").strip()
if alias and val:
out[alias] = val
return out
```
- [ ] **Step 4: 跑测试确认通过 + 全文件回归**
Run: `python -m pytest tests/test_contact_pipeline.py -v`
Expected: 全 PASS
- [ ] **Step 5: 提交**
```bash
git add app/services/contact_pipeline.py tests/test_contact_pipeline.py
git commit -m "fix(contact): key 归一化精确优先+contains兜底——extractor 变体 key 不再丢值"
```
---
### Task 4: App 索要提醒按已留资门控(拆确定性索要引擎①)
**原理:** [conversation.py:497-508](app/services/conversation.py#L497-L508) 的 App 分支逐字脚本 "Drop your WhatsApp..." 在 `CONTACT_ASK_TRIGGER_RE` 命中时无条件注入,哪怕 confirmed/suspect 已存在——同一条提醒里"禁止再索要"与"逐字 Drop your WhatsApp"互相打架,而 changelog §3.2 已实证 GLM 听逐字不听抽象。改为:已留资时注入"经理会主动联系你"版逐字脚本。web 分支不动。
**Files:**
- Modify: `app/services/conversation.py:496-508、524-553`
- Test: `tests/test_lead_email_trigger.py`
- [ ] **Step 1: 写失败测试**
```python
def test_reminder_app_contact_ask_gated_by_existing_contact():
# App 会话已留资 + 再次索要我方联系方式 → 不得注入 "Drop your WhatsApp" 索要脚本
raw = {"联系方式": {"whatsapp": "+254712345678"}}
text = ConversationManager._build_context_reminder(
raw, "how can I contact you", [], source="app")
assert "Drop your WhatsApp" not in text
assert "reach out to you at your WhatsApp" in text # 已留资版逐字脚本
def test_reminder_app_contact_ask_without_contact_keeps_script():
# App 会话未留资 → 原反向留资脚本原样保留
text = ConversationManager._build_context_reminder(
{}, "how can I contact you", [], source="app")
assert "Drop your WhatsApp" in text
def test_reminder_web_branch_unchanged():
# web 分支不受影响
text = ConversationManager._build_context_reminder(
{}, "how can I contact you", [], source="widget")
assert "tap the WhatsApp button" in text.lower()
```
- [ ] **Step 2: 运行确认失败**
Run: `python -m pytest tests/test_lead_email_trigger.py -k "app_contact_ask or web_branch_unchanged" -v`
Expected: 门控测试 FAIL(现无条件注入 Drop 脚本);后两个 PASS
- [ ] **Step 3: 实现——联系方式状态前移,App 分支三态**
`_build_context_reminder` 中,把 contact_note 构建移到联系方式判定之后。结构变为:
```python
# 联系方式状态前移(供 boundary/contact 提醒共用判定)
contacts_all = normalize_contacts(raw_info.get("联系方式"))
confirmed, suspect = split_validate(contacts_all)
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 = ("⚠️ 客户正在索要我方联系方式(最高意向信号)。请逐字使用这句指路:"
"\"Just tap the WhatsApp button at the top of this chat — "
"our manager replies fast!\"(邮箱同理指 Email 按钮)。"
"绝对禁止口头报出任何号码或邮箱。")
```
下方原 `contacts_all = normalize_contacts(...)` / `confirmed, suspect = split_validate(...)` 两行(约 524-525 行)删除(已前移),其余 confirmed/suspect 渲染逻辑原样保留。早退分支 `if not raw_info:` 在 contact_note 之后、前移的联系方式判定之后——注意早退时 confirmed/suspect 已算好,直接可用(空画像必然双空,行为不变)。
- [ ] **Step 4: 跑测试确认通过 + 提醒全组回归**
Run: `python -m pytest tests/test_lead_email_trigger.py -k reminder -v`
Expected: 全 PASS(含旧 6 个 reminder 测试)
- [ ] **Step 5: 提交**
```bash
git add app/services/conversation.py tests/test_lead_email_trigger.py
git commit -m "fix(contact): App 索要提醒按已留资门控——已留资注入'经理主动联系'逐字脚本"
```
---
### Task 5: 零号码墙替换句按 has_contact 路由(拆确定性索要引擎②)
**原理:** [conversation.py:216-217](app/services/conversation.py#L216-L217) 的 no_contact 分支不查 has_contact,`NO_CONTACT_APP` 文案本身就是索要——AI 回显号码形态稍有出入触发墙后,替换句在客户刚发完号之后立刻再要一次。与 F6 修价格墙的同款矛盾,用同款修法。
**Files:**
- Modify: `app/services/boundary_filter.py:119-122`
- Modify: `app/services/conversation.py:209-217`
- Test: `tests/test_boundary_filter.py`、`tests/test_lead_email_trigger.py`
- [ ] **Step 1: 写失败测试**
`tests/test_boundary_filter.py`:
```python
def test_no_contact_already_template_clean():
from app.services.boundary_filter import NO_CONTACT_ALREADY
assert not no_contact_value(NO_CONTACT_ALREADY) # 模板自身无号码/邮箱
assert "WhatsApp?" not in NO_CONTACT_ALREADY # 不索要
```
`tests/test_lead_email_trigger.py`:
```python
def test_no_contact_wall_already_contact_no_reask(async_db, monkeypatch):
# 已留资 + 墙触发(AI 回显形态出入) → 替换句不得再索要
from app.services.boundary_filter import NO_CONTACT_ALREADY
class EchoAI(FakeAI):
async def stream_chat(self, messages, context=""):
# 客户发的是 254712 345678,AI 回显错成 254712 345999(改中间数字,
# 不是子串——回显豁免是子串判定,"54712345678" 这种去首位形态仍是
# "254712345678" 的子串,豁免会放行,墙根本不触发,测试变空转)
for token in ("got it, ", "254712 345999 call you soon"):
yield token
(manager, conv), _ = _setup(
async_db, monkeypatch,
ai=EchoAI(extract={}), source=ConversationSource.standalone)
asyncio.run(_send(manager, conv.session_id, "my whatsapp is 254712 345678"))
asyncio.run(_send(manager, conv.session_id, "so how much is tang?"))
msgs = asyncio.run(manager._load_history(conv.id))
tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1]
# 精确断言(评审加固): 负向断言("WhatsApp?" not in)对三种替换句都空转通过,
# 必须钉死路由结果本身
assert tail == NO_CONTACT_ALREADY
asyncio.run(manager.db.close())
```
- [ ] **Step 2: 运行确认失败**
Run: `python -m pytest tests/test_boundary_filter.py::test_no_contact_already_template_clean tests/test_lead_email_trigger.py::test_no_contact_wall_already_contact_no_reask -v`
Expected: FAIL(`NO_CONTACT_ALREADY` 不存在 / 替换句为 `NO_CONTACT_WEB`)
- [ ] **Step 3: 实现**
`app/services/boundary_filter.py` 常量区加:
```python
# F6 同款(15.3 补): 已留资时零号码墙替换句不得索要
NO_CONTACT_ALREADY = ("Got it — our manager will reach out to you at your "
"WhatsApp shortly with the full quote!")
```
`app/services/conversation.py` no_contact 分支改为:
```python
if violation == "no_contact":
# 零号码墙替换句: 已留资→确认话术(不再索要);未留资按 source 分支
closing = (NO_CONTACT_ALREADY if has_contact
else (NO_CONTACT_APP if conv.source.value == "app"
else NO_CONTACT_WEB))
```
(import 行补 `NO_CONTACT_ALREADY`。)
- [ ] **Step 4: 跑测试确认通过 + 全量回归**
Run: `python -m pytest tests/ -q`
Expected: 全绿
- [ ] **Step 5: 提交**
```bash
git add app/services/boundary_filter.py app/services/conversation.py \
tests/test_boundary_filter.py tests/test_lead_email_trigger.py
git commit -m "fix(contact): 零号码墙替换句按 has_contact 路由——已留资不再索要(F6 同款)"
```
---
### Task 5b: 再索要墙(已留资时输出侧确定性拦截索要句)
**原理(生产日志实证补强):** 2026-09 真机日志显示,4 次违规索要**全部**是 GLM 自由生成(逐字复述 system.md 留资话术模板),既非价格墙替换句、亦非 App 脚本——Task 1/4/5 的确定性门控对此零拦截,Task 8 提醒逐字化是唯一防线但属概率层。日志同时证明:捕获链工作正常(整条裸号确定性命中)、GLM 看得见历史仍照问("Got your WhatsApp?"出现在确认收号 19 秒后)。本任务补第三道墙:`has_contact`(confirmed)为真时,输出中的"索要联系方式"疑问/祈使句**逐句静默丢弃**(不掐断流,其余句子照常流出);`has_contact` 为假时闸门全关——首次合法索要不受影响。
**误杀边界(设计即约束):**
- 确认句不拦:"Got your number, thanks!" 无问号、动词不在请求表 → 放行
- suspect 礼貌确认不拦:动词表无 confirm/share your number again 等"确认"语义,问句 "Could you confirm your number?" 放行
- 按钮指路不拦:"Just tap the WhatsApp button…" 陈述句 → 放行
- 聊车问句不拦:"What's your budget range?" 无渠道词 → 放行
- 所有模式强制要求**渠道词在场**,防 "Can you send me the details?" 类泛请求误杀
**Files:**
- Modify: `app/services/boundary_filter.py`(REASK_PATTERNS + `asks_contact`)
- Modify: `app/services/conversation.py`(句子门控循环接线,丢句不掐断)
- Test: `tests/test_boundary_filter.py`、`tests/test_lead_email_trigger.py`
- [ ] **Step 1: 写失败单测(生产日志原句逐字钉死 + 误杀防护)**
`tests/test_boundary_filter.py`:
```python
# ---- Task 5b: 再索要墙 ----
from app.services.boundary_filter import asks_contact
def test_asks_contact_detects_production_log_phrasings():
# 2026-09 生产日志 4 次违规索要原句 + system.md 全部留资模板同型句,逐字钉死
# (⚠️ 首版模式曾漏 "I'll need your WhatsApp"/"Tell me your WhatsApp" 两句
# system.md 模板原句——GLM 背的就是这些,必须全收;已实证 13/13)
assert asks_contact("What's your WhatsApp or email?")
assert asks_contact("Could you leave your WhatsApp?")
assert asks_contact("Got your WhatsApp so our manager can send the details?")
assert asks_contact("Got your WhatsApp?")
assert asks_contact("Drop your WhatsApp and our manager will reach out to you directly with the full quote!")
assert asks_contact("Want me to get you a proper quote? I'll need your WhatsApp so our manager can reach you.")
assert asks_contact("Tell me your WhatsApp and our manager will send you an accurate quote for your budget.")
assert asks_contact("leave your contact and our manager will give you an exact schedule")
assert asks_contact("Drop yours too and our manager will send you the full quote!")
assert asks_contact("留个WhatsApp或者邮箱吧?")
assert asks_contact("你WhatsApp多少?")
assert asks_contact("你给我个联系方式,我让客户经理给你推几个车型。")
def test_asks_contact_clean_phrasings_pass():
assert not asks_contact("Got your number, thanks!") # 确认,非问句
assert not asks_contact("Could you confirm your number?") # suspect 礼貌确认
assert not asks_contact("Just tap the WhatsApp button at the top of this chat — our manager replies fast!")
assert not asks_contact("Which color do you prefer?")
assert not asks_contact("What's your budget range for this one?")
assert not asks_contact("Can you send me more details about the Tang?") # 泛请求无渠道词
assert not asks_contact("Our manager will send you all the details.")
assert not asks_contact("I want your advice on the Song L EV.") # want+非渠道词
assert not asks_contact("Could you drop it here once more?") # 核对恢复话术(suspect场景)
assert not asks_contact("Our manager needs your config and destination.") # 合法迂回句
```
- [ ] **Step 2: 运行确认失败**
Run: `python -m pytest tests/test_boundary_filter.py -k asks_contact -v`
Expected: FAIL(`asks_contact` 未定义)
- [ ] **Step 3: 实现 `asks_contact`**
`app/services/boundary_filter.py` 零号码墙区之后:
```python
# ---- Task 5b: 再索要墙(已留资时输出侧拦截) ----
# 仅当调用方 has_contact=True 才启用(接线在 conversation.py);
# 每条模式强制渠道词在场,防泛请求误杀("Can you send me the details?" 放行)。
# 模式集与 system.md 全部留资话术模板逐句对齐并实证(13 命中/12 放行)——
# GLM 背的就是这些模板,模板全集必须全收。
REASK_PATTERNS = [
# what's/which's your <渠道词>
re.compile(r"(?:what|which)['′]?s\s+(?:your|the)\s+"
r"(?:whatsapp|number|phone|e-?mail|contact|号码|联系方式)", re.I),
# got/have your <渠道词> ... ?(问号兜尾——确认句 "Got your number, thanks!" 无问号放行)
re.compile(r"(?:got|have)\s+(?:your|the)\s+"
r"(?:whatsapp|number|phone|e-?mail|contact)[^!?.]{0,60}\?", re.I),
# 陈述式需求: (I'll) need/want your <渠道词>——"I'll need your WhatsApp" 无问号也要拦
re.compile(r"(?:i['′]ll\s+|i\s+)?(?:need|want)\s+(?:your|the)\s+"
r"(?:whatsapp|number|phone|e-?mail|contact|号码|联系方式)", re.I),
# Tell me your <渠道词>
re.compile(r"tell\s+me\s+(?:your\s+)?"
r"(?:whatsapp|number|phone|e-?mail|contact|号码|联系方式)", re.I),
# could/can you + 给予动词 + ... 渠道词(动词表无 confirm/send——礼貌确认与泛请求放行)
re.compile(r"(?:could|can|would|may)\s+you\s+"
r"(?:leave|drop|share|give|provide|type|post)[^.!?\n]{0,30}"
r"(?:whatsapp|number|phone|e-?mail|contact|号码|联系方式|微信|邮箱)", re.I),
# 祈使: leave/drop/share/give (me) your/us a <渠道词>
re.compile(r"(?:leave|drop|share|give)\s+(?:me\s+)?(?:your|us\s+your?|a)\s+"
r"(?:whatsapp|contact|number|e-?mail|联系方式)", re.I),
# 互留后缀 "Drop yours too..."(无渠道词,单列)
re.compile(r"(?:drop|leave)\s+yours\b", re.I),
# "WhatsApp or email?" 独立短问
re.compile(r"(?:whatsapp|e-?mail|contact)\s+or\s+(?:whatsapp|e-?mail|contact)\s*\?", re.I),
# 中文
re.compile(r"留[个下]?\s*(?:你的?)?\s*(?:whatsapp|微信|邮箱|联系方式|号码|电话)|"
r"给[我一]?个?\s*(?:联系方式|whatsapp|微信|号码|邮箱|电话)|"
r"你的?\s*(?:whatsapp|号码|电话|微信|邮箱|联系方式)[^。!?]{0,10}(?:是多少|多少|\?)", re.I),
]
def asks_contact(sentence: str) -> bool:
"""输出句是否在索要联系方式(仅在客户已留资时用于丢弃)。"""
return any(p.search(sentence or "") for p in REASK_PATTERNS)
```
- [ ] **Step 4: 跑单测确认通过 + 提交**
Run: `python -m pytest tests/test_boundary_filter.py -k asks_contact -v`
Expected: PASS
- [ ] **Step 5: 写失败集成测试(丢句不掐断,其余照常流出)**
`tests/test_lead_email_trigger.py`:
```python
def test_reask_wall_drops_ask_sentence_when_contact(async_db, monkeypatch):
# 生产日志复现: 已留资 + GLM 仍在回复中夹索要句 → 该句静默丢弃,前后句照常
class ReAskAI(FakeAI):
async def stream_chat(self, messages, context=""):
for token in ("Great choice! ", "Got your WhatsApp? ",
"Manager will send the details."):
yield token
(manager, conv), _ = _setup(async_db, monkeypatch,
ai=ReAskAI(extract={}),
source=ConversationSource.standalone)
asyncio.run(_send(manager, conv.session_id, "00963944526529")) # 留资
asyncio.run(_send(manager, conv.session_id, "i want 2model"))
msgs = asyncio.run(manager._load_history(conv.id))
tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1]
assert "Got your WhatsApp" not in tail # 索要句被丢
assert "Great choice" in tail # 前句保留
assert "send the details" in tail # 后句保留
asyncio.run(manager.db.close())
```
- [ ] **Step 6: 运行确认失败(索要句未被丢,整段流出)**
Run: `python -m pytest tests/test_lead_email_trigger.py::test_reask_wall_drops_ask_sentence_when_contact -v`
Expected: FAIL
- [ ] **Step 7: 接线——句子门控循环加第三道检查(丢句不掐断)**
`app/services/conversation.py` 流式循环内,价格墙与零号码墙检查之后、流出之前:
```python
# Task 5b 再索要墙: 已留资时索要句静默丢弃(丢句不掐断,
# 其余句子照常流出——软于两墙的掐断+替换)
if has_contact and asks_contact(sentence):
logger.info("Re-ask wall dropped sentence (conv=%s): %r",
conv.id, sentence[:60])
continue
```
流末尾 `pending` 检查处(`if not violated and pending:` 分支)补同款:命中则不流出、记日志。(import 行补 `asks_contact`。)
- [ ] **Step 8: 跑集成测试 + 全量回归 + 提交**
Run: `python -m pytest tests/ -q`
Expected: 全绿
```bash
git add app/services/boundary_filter.py app/services/conversation.py \
tests/test_boundary_filter.py tests/test_lead_email_trigger.py
git commit -m "fix(contact): 再索要墙——已留资时输出侧确定性丢弃索要句(生产日志实证补强)"
```
---
### Task 6: CONTACT_ASK_TRIGGER_RE 排除将来时误触发
**原理:** "I'll call you later" 命中 `(call)\s+(you)` → App 会话注入索要脚本(已由 Task 4 门控缓解,但未留资客户仍会被莫名要号,且 web 分支也会无谓注入)。用固定宽度负向后顾排除第一人称将来时,不影响真问("how can I contact you" 的 contact 在疑问结构里,前面不是 i'll)。
**Files:**
- Modify: `app/services/conversation.py:55-61`
- Test: `tests/test_lead_email_trigger.py`
- [ ] **Step 1: 写失败测试**
```python
from app.services.conversation import CONTACT_ASK_TRIGGER_RE
def test_contact_ask_trigger_ignores_future_first_person():
assert not CONTACT_ASK_TRIGGER_RE.search("I'll call you later")
assert not CONTACT_ASK_TRIGGER_RE.search("I will email you the specs")
assert not CONTACT_ASK_TRIGGER_RE.search("let me call you tonight")
def test_contact_ask_trigger_still_fires_on_real_asks():
assert CONTACT_ASK_TRIGGER_RE.search("how can I contact you?")
assert CONTACT_ASK_TRIGGER_RE.search("what's your whatsapp number")
assert CONTACT_ASK_TRIGGER_RE.search("你们怎么联系")
assert CONTACT_ASK_TRIGGER_RE.search("can I call you?") # 疑问结构仍触发
```
- [ ] **Step 2: 运行确认失败**
Run: `python -m pytest tests/test_lead_email_trigger.py -k contact_ask_trigger -v`
Expected: 误触发组 FAIL(现命中)
- [ ] **Step 3: 实现——负向后顾(定宽,`re` 支持)**
```python
CONTACT_ASK_TRIGGER_RE = re.compile(
r"(your|our)\s+(whatsapp|number|phone|email|contact)|"
r"(?<!i'll )(?<!i’ll )(?<!i will )(?<!let me )"
r"(contact|reach|call|email)\s+(you|us)|"
r"你(们)?的?\s?(whatsapp|号码|电话|微信|邮箱|联系方式)|"
r"(怎么|如何)联系|给我.{0,4}(号码|电话|联系方式)",
re.IGNORECASE,
)
```
(⚠️ 评审实证:不能用负向前顾 `(?!let me call you)` 排除——前顾在 `(contact|call|...)` 起始位置求值,那里只有 "call you...",永远看不到 "let me",拦不住。四个负向后顾均为定宽(`i'll `/`i’ll ` 各 5 字符、`i will ` 7、`let me ` 7),Python `re` 合法。`i’ll ` 是弯引号变体,手机输入法常见。"can I call you" 前文是 "an I " 不拦——客户问我们能否打电话属真意向,保持触发。)
- [ ] **Step 4: 跑测试确认通过 + 提交**
Run: `python -m pytest tests/test_lead_email_trigger.py -k contact_ask_trigger -v`
Expected: PASS
```bash
git add app/services/conversation.py tests/test_lead_email_trigger.py
git commit -m "fix(contact): 索要触发词排除第一人称将来时——I'll call you later 不再误触"
```
---
### Task 7: system.md"假称已留过号"改为先查记录(prompt 层)
**Files:**
- Modify: `app/prompts/system.md:62`
- [ ] **Step 1: 修改话术**
原:
```
- 假称已留过号("I gave you my number already")→ 温和核对:"Sorry, I may have missed it — could you drop it here once more?"
```
改为:
```
- 客户说"我已经给过号码了"("I gave you my number already")→ **先回看聊天记录**:
记录里确有号码 → 确认收到("Got it — our manager will reach out to you at your WhatsApp shortly!"),绝不要求重发;
记录里确实没有 → 才温和核对:"Sorry, I may have missed it — could you drop it here once more?"
```
- [ ] **Step 2: 既有提醒测试回归(prompt 改动无单测,靠提醒/墙测试守行为)+ 提交**
Run: `python -m pytest tests/test_lead_email_trigger.py tests/test_boundary_filter.py -q`
Expected: 全绿(prompt 文件改动不影响这些测试,跑一遍防意外)
```bash
git add app/prompts/system.md
git commit -m "fix(prompt): 假称已留过号先查记录——确有号码时确认收到,不要求重发"
```
---
### Task 8: 提醒"已提供"段附逐字话术(GLM 依从性加固)
**原理:** changelog §3.2 实证:GLM 无视抽象指令、执行逐字话术。"绝对禁止再索要"是抽象禁令,附一句可直接照抄的话术把依从性从"理解"降为"复制"。
**Files:**
- Modify: `app/services/conversation.py:526-530`(Task 4 后行号会移,定位 confirmed 渲染段)
- Test: `tests/test_lead_email_trigger.py`
- [ ] **Step 1: 写失败测试**
```python
def test_reminder_confirmed_includes_verbatim_script():
raw = {"联系方式": {"whatsapp": "+254712345678"}}
text = ConversationManager._build_context_reminder(raw, "yes", [])
assert "绝对禁止再索要" in text
assert "Got it — our manager will reach out" in text # 逐字话术在场
```
- [ ] **Step 2: 运行确认失败**
Run: `python -m pytest tests/test_lead_email_trigger.py::test_reminder_confirmed_includes_verbatim_script -v`
Expected: FAIL
- [ ] **Step 3: 实现——confirmed 渲染段加一句**
```python
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"确认收到后继续解答客户问题即可,不必急着结束对话。")
```
- [ ] **Step 4: 跑测试确认通过 + 提交**
Run: `python -m pytest tests/test_lead_email_trigger.py -k reminder -v`
Expected: 全 PASS
```bash
git add app/services/conversation.py tests/test_lead_email_trigger.py
git commit -m "fix(contact): 已留资提醒附逐字话术——GLM 依从性加固(听逐字不听抽象)"
```
---
### Task 9: 端到端回归(用户报告场景)
- [ ] **Step 1: 写端到端测试——客户发号→继续聊,全程零再索要**
```python
def test_e2e_no_reasking_after_contact_provided(async_db, monkeypatch):
# 用户报告场景复现: 发号(倒装形态)→继续问价→再闲聊,三轮 AI 回复零索要
class StrictAI(FakeAI):
def __init__(self, extract):
super().__init__(extract=extract)
self.contexts = []
async def stream_chat(self, messages, context=""):
self.contexts.append(context)
for token in ("sure thing", ):
yield token
ai = StrictAI(extract={}) # extractor 全程失明,只靠正则管道
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai,
source=ConversationSource.standalone)
asyncio.run(_send(manager, conv.session_id, "hi there"))
asyncio.run(_send(manager, conv.session_id,
"0712 345 678 is my whatsapp btw"))
asyncio.run(_send(manager, conv.session_id, "so how much is the Tang?"))
asyncio.run(_send(manager, conv.session_id, "and shipping to Kenya?"))
# 第2轮起每轮提醒都必须含"已提供+禁止再索要"
for ctx in ai.contexts[1:]:
assert "已提供" in ctx and "禁止" in ctx, ctx[:200]
# AI 回复里没有索要话术(StrictAI 不索要;此断言守住提醒确实在场,
# 索要与否由提醒+墙两层守,墙的确定性已由前序任务覆盖)
asyncio.run(manager.db.close())
```
- [ ] **Step 2: 运行(应直接通过——这是验收性测试,若 FAIL 说明前序任务有漏)**
Run: `python -m pytest tests/test_lead_email_trigger.py::test_e2e_no_reasking_after_contact_provided -v`
Expected: PASS
- [ ] **Step 3: 全量测试 + 提交**
Run: `python -m pytest tests/ -q`
Expected: 全绿
```bash
git add tests/test_lead_email_trigger.py
git commit -m "test(contact): 端到端回归——发号后三轮零再索要(extractor失明场景)"
```
---
### Task 10: 改动文档 + 真机冒烟(仓库惯例)
- [ ] **Step 1: 新建 `docs/2026-09-08-stop-reasking-contact-changelog.md`**
按 `docs/2026-08-20-company-contact-handoff-changelog.md` 的体例:背景(根因 1-6 一句话各) / 三层实现对照表 / 文件清单 / 已知限制(见计划"残余风险"节,原样转录) / 部署与回滚(重启生效,逐 commit 可回滚,无迁移)。
- [ ] **Step 2: 真机 GLM 冒烟(prompt 层与提醒依从性的唯一验证手段,单测覆盖不到)**
至少覆盖以下场景,逐条记录结果进 changelog(参照 2026-08-20 文档 §5 的表格式):
| 场景 | 期望 |
|---|---|
| 发号后继续问价(同轮:"my whatsapp is +86..., how much?") | 确认收到 + 不再要号,报价转经理话术 |
| 倒装发号("0712 345 678 is my whatsapp")后闲聊 2 轮 | 全程零索要 |
| App 会话发号后再问"how can I contact you" | "经理会主动联系你"版话术,无 Drop your WhatsApp |
| "I'll call you later" | 不触发索要脚本,正常道别 |
| 发号后说 "I gave you my number already" | 先查记录确认收到,不要求重发 |
无 GLM key 时标注"未冒烟"并保留该表为部署前置项——**prompt 依从性(Task 7/8)只有这条验证路径,跳过即盲发**。
- [ ] **Step 3: 提交**
```bash
git add docs/2026-09-08-stop-reasking-contact-changelog.md
git commit -m "docs: 已留资不再索要——改动文档"
```
---
## 残余风险(实现时须知,文档 Task 10 需转录)
1. **同轮扫描仍是正则**——Task 2 扩充后仍存在未覆盖表述(如纯语音转写噪声、极端缩写);当轮失守时下一轮由轮末管道兜底。正则永远不是全集,这是防御纵深的第一层而非全部。
2. **prompt 层(Task 7)不可单测**——GLM 依从性靠真机冒烟;确定性防线(Task 1/4/5)才是主修,prompt 是 belt。
3. **`NO_CONTACT_ALREADY` 英文单语**——与既有 `NO_CONTACT_WEB/APP` 一致(既有设计如此,中文会话也会收到英文替换句);如需中英路由属独立增强,不在本计划。
4. **墙路由用 confirmed-only**——suspect(疑似)时墙仍可能问一次;这是有意的(suspect 本来就该礼貌确认一次)。
5. **contains 兜底可能误绑怪 key**——如 extractor 输出 `"无email"` 作 key 会映射 email;概率极低(渠道名作 key 是常态语义),且 value 仍过 split_validate 校验,风险受控。
6. **新正则进入 `latest_stated_contacts` 即获得覆盖权**——倒装/msg-me 形态语义上就是显式自述,授予权正确。倒装形态**只收显式渠道词**(whatsapp/wa/wechat/微信):自查实证 "2500000 is my number one choice" 这类排位表述会把价格误绑成号码并经覆盖权翻新真实号码,故 number/phone/号码 不进倒装正则;已知误报形态已用防误报测试钉住。
7. **测试基线**——开工前先 `python -m pytest tests/ -q` 记录基线数(应为 117+),收尾时对齐;任何既有测试变红即停。
8. **同轮改号场景的视图显示旧值**——Task 1 的同轮视图沿用 `merge_contacts(current_scan, 已存值)` 方向(已存优先):客户先留过号、当轮发新号时,当轮提醒显示的仍是旧号。这不影响"禁止再索要"(任一联系方式在场即触发禁令),下一轮轮末管道即校正;反向合并(新值优先)会引入"当轮误捕获顶掉好值进提醒"的新面,两害取轻,维持现方向。
9. **触发词的 we/they 将来时不排除**——"We will call you tomorrow" 仍触发索要提醒(Task 6 只排 i'll/i will/let me)。已留资时被 Task 4 门控兜住(注入的是"经理主动联系"版),未留资时多注入一次反向留资脚本——语义无害,不加更多后顾以维持正则可读。
10. **再索要墙是模式匹配,不是语义理解**——Task 5b 已把 system.md 全部留资模板与生产日志原句钉进测试(13 命中/12 放行,全部实证),但 GLM 改写措辞可能绕过模式(漏拦)或撞上边缘句式(误拦)。护栏:仅 has_contact=True 时启用、每条模式强制渠道词、丢句不掐断(误拦只损失一句,对话不中断)。上线后拦截日志(`Re-ask wall dropped sentence`)回流补模式,与既有"漏词案例回流补词"同机制。
@@ -0,0 +1,419 @@
# 公司知识库(Hesu Auto 事实卡)实施方案
> **状态:方案定稿——四个待确认问题已全部确认(2026-09-10),待批准执行。**
**Goal:** 给 AI 客服补充公司事实知识(品牌/覆盖/网络/经验),使客户问公司情况时**零编造、简短、可信**,且不改变职能边界(不报价/不承诺/不报联系方式)。
**Architecture:** 新建独立事实卡文件 `app/prompts/company_facts.md`(知识与行为分离),由 `ai_service.py` 拼接进对话 prompt(+3 行 loader;顺序:system.md 行为 prompt → 事实卡 → 当轮 context 提醒);事实卡内容经输出墙安全审查(每句过价格墙+零号码墙);不动 extractor/evaluator/前端/配置。
**Tech Stack:** 零新依赖、零新基础设施。纯 prompt 文件 + 2 行 loader 改动 + 静态安全测试。
---
## 1. 背景与问题
现状:`system.md` 是纯行为 prompt,**不含任何公司事实**。客户问"你们是什么公司/有哪些品牌/送不送到肯尼亚/是不是真的"时,GLM 只能编造。这正是 2026-08-20 changelog 记录过的失效模式——**"prompt 不含真值 → GLM 编造假号"的同款问题,只是从号码换成了公司事实**(编造品牌/国家/年限=信任崩塌,客户流失)。
**生产实证(2026-09,Ghana 案例)**:客户问 "I'm in Ghana, do you have branch in Ghana",AI 答 **"Not yet, but we've got partners worldwide..."**——而公司**在 Ghana 有分支**(五分支之一,且有保税仓)。客户就在 Ghana、公司就在当地有网点,本是信任建立的绝佳时机,被知识真空变成当面否认。诊断:GLM 对无法验证的事实问题选择"听起来合理的否定+转进"(hedging),**否定性编造与肯定性编造同样致命**;且此类问题("do you have branch in X")没有特征触发词(只有国名),触发词注入方案(C)无法覆盖——这是选型 A(事实常驻)的最强论据。
**编造面全量盘点(2026-09-10,用户发起)**:除公司事实外又发现两个编造向量,均折入 Task 3/6——
1. **库存/现车断言**:system.md 红线禁"库存承诺",但其"好回复"示例有三处在教 AI 说 "we got tons of them"/"in stock"——本会话已两次实证 GLM 听逐字示例不听抽象红线,示例教唆必被模仿;AI 无任何库存数据源,照示例回答=编造现货。(修正:Task 3 改动 3,按文本锚点定位)
2. **保修/质保承诺**:价格墙时间模式显式豁免 warranty(`(?!\s*(warranty|质保|保修))`,[boundary_filter.py:22](app/services/boundary_filter.py#L22))——AI 若输出编造的 "6 months warranty" 承诺,墙级零拦截(实证)。**修法升级:Task 6 墙级收紧**(prompt 红线 Task 3 改动 4 为 belt)。复查中还发现**中文时限/质保承诺("3天到货"/"6个月质保")今天全部零拦截**(时间模式只有英文单位)——Task 6 一并补。
其余编造面(车型公共参数过期、认证资质)接受为观察项,见 §6。
知识库要解决的四类真实问题:
| 客户问题类型 | 例子 | 需要的事实 |
|---|---|---|
| 信任核实 | "are you a real company?" | 首批出口企业/集团背景/累计出口量 |
| 品牌/产品 | "do you have ZEEKR? trucks?" | 30+ 品牌清单/动力类型/车型类别 |
| 覆盖范围 | "do you ship to Ghana?" | 70+ 国家/五分支/Dubai 枢纽 |
| 运作模式 | "how does this work?" | 中国采购→迪拜枢纽→海外交付 |
## 2. 方案选型
| | A. 静态事实卡(推荐) | B. RAG 检索 | C. 触发词条件注入 |
|---|---|---|---|
| 做法 | 事实卡文件常驻 prompt | 向量/关键词检索,按轮注入相关块 | 仿 CONTACT_ASK_TRIGGER_RE,问公司才注入 |
| 优点 | 零新设施;事实常在,漏触发=零;单 commit 回滚;与现有架构(prompt 驱动)完全一致 | token 省;可扩展到车型库存/FAQ | token 省 |
| 缺点 | 每轮 +~700-900 token(实测草案 1289 chars;glm-4-flash 成本可忽略) | 需引入检索设施(本项目零基础);**检索漏=编造风险回归**;新失效模式 | 触发漏(客户间接问)→ 编造风险回归 |
| 结论 | ✅ **本期采用** | KB 长大到库存/FAQ 规模再议 | 事实卡膨胀后再优化,loader 设计已留位 |
**选 A 的决定性理由**:本项目的可靠性哲学是"确定性优先"——RAG/触发词都引入"该注入时没注入"的 probabilistic 失效,而编造的代价(信任崩塌)远高于每轮几百 token。
## 3. 事实卡内容草案(即 Task 1 的文件内容)
```
# 公司事实卡(仅供回答客户关于公司的事实性问题)
## 我是谁
- Hesu Auto 是 Hesu Group 旗下专注汽车出口的公司
- 中国首批汽车出口企业之一,累计出口 50,000+ 台(用户已确认,2026-09-10)
- **累计出口到 70+ 国家与地区**(中东、非洲、南美、欧洲)——⚠️ 出口目的国口径,不是分支/网点覆盖(用户明确区分,2026-09-10)
- 与 30+ 汽车品牌深度合作:BYD、Changan、Geely、Leopard、ROX、Nissan、Zeeker、Li Auto 等
## 卖什么
- **新车与二手车都做**(用户已确认)
- 全动力类型:燃油、混动、插混、纯电
- 全车型类别:轿车、SUV、皮卡、重卡;乘用车、商用车、新能源车、车队/项目采购
## 网络与运作
- 模式:中国采购供应 → 迪拜(Jebel Ali)枢纽备货分拨 → 海外交付
- 海外分支五处:Dubai、Ghana、Ethiopia、Djibouti、Belarus
- Abu Dhabi 有展车节点;Ghana 西非保税仓;Djibouti+Ethiopia 东非协同;Venezuela(南美)在网络节点内(用户确认列入)
## 使用规则(与 system.md 红线同级)
- **统一兜底(最高优先,用户定调 2026-09-10)**:任何不清楚、事实卡没有、需要核实的问题——**不编造、不承诺、不否定、不断言**,统一动作只有一个:顺势引导留联系方式转客户经理——"这个我让我们经理给你准确答复——留个 WhatsApp,他直接联系你"
- 客户问公司情况/品牌/是否可信时,取上述事实**简短作答(1-2 句)**,建立信任
- **分支问题与出口覆盖问题是两回事,分开答**:
- 问分支/网点("do you have branch in X")→ 只认卡里列的:五分支+节点(Dubai/Ghana/Ethiopia/Djibouti/Belarus/Abu Dhabi/Venezuela)必须答"有"并点出当地节点;卡里没有的 → **走统一兜底**——绝对禁止编造否定答案("Not yet"/"we don't have"/"没有")(Ghana 案例教训:公司明明有分支被答成 Not yet)
- 问出口覆盖("do you ship to X")→ 70+ 国是**累计出口目的国**,不代表 X 一定有网点:答"已出口到 70+ 国家"事实 + **走统一兜底**(X 具体情况经理确认)——不承诺、不否定
- 绝不主动长篇介绍公司,绝不用成营销腔——风格仍按【说话风格】
- 涉及具体价格/交付时效/库存承诺 → 仍按【职能边界】转客户经理,本卡不改变任何红线;售后只说"有本地协同支持",不做任何保证
- 事实卡没写的细节(某国代理、某车型现车、认证资质细节)→ **走统一兜底**(不编造,留资+经理沟通)
- 数字一律照抄本卡写法(如 50,000+、70+),不改写成中文数字
```
### 内容取舍说明(对照原始资料)
| 原资料内容 | 处置 | 理由 |
|---|---|---|
| About 正文全篇 | 压缩为"我是谁"4 行 | 营销语气("strives to win recognition")违背说话风格;KB 只要事实 |
| Contact Us 节 | **整节剔除** | 红线:AI 绝不输出我方联系方式(web 指按钮/App 反向留资,2026-08-20 已定) |
| "Why Work with HESU" 四点 | 融入"网络与运作"1 行 | 与 Global Network 高度重复,留骨架 |
| Key Facts "10,000+" | 不采信 | 与 About "50,000+" 矛盾,用户确认以 **50,000+** 为准 |
| "72+ Countries" | 取 "70+" | About 正文"more than 70"更稳,两处兼容 |
| "Zeeker" | **保留原文 Zeeker**(用户确认) | 用户口径为准,不作 ZEEKR 规范化 |
| Venezuela | **列入**(用户确认) | 仅网络图出现,按用户口径列为网络节点 |
| 新车/二手车定位 | **新旧车都做**(用户确认) | 人设第一句同步改为"汽车出口外贸销售(新车与二手车都做)",Task 3 一并处理 |
| Leopard、ROX 品牌名 | **保留原文**(用户确认) | 照抄入卡:Leopard、ROX |
## 4. 与现有防线/风格的兼容性审查(本方案的核心约束)
| 审查项 | 结论 | 说明 |
|---|---|---|
| **价格墙** `violates_boundary` | ⚠️ 有陷阱,已规避 | `五万` 这类**中文数字+万**会被裸预算模式击杀(`(两|三...十)\s*万`),AI 引用即被墙拦截替换成迂回话术——张冠李戴。**事实卡强制阿拉伯数字**(50,000+),Task 2 的墙扫描测试会把关 |
| **零号码墙** `no_contact_value` | ✅ 安全 | 卡内数字均 <10 位(50,000 逗号断开=5 位);无邮箱;Task 2 扫描钉死——**同时防止未来有人往 KB 里加电话/邮箱** |
| **再索要墙** `asks_contact` | ✅ 交互正确(实证) | 使用规则里的兜底话术"留个 WhatsApp,他直接联系你"**命中再索要墙**——客户已留资时 GLM 复读兜底话术会被正确丢弃(墙换成"经理主动联系"版),未留资时放行(合法引导)。两层设计自洽,无需处理 |
| **职能边界** | ✅ 显式锁定 | 使用规则第 3 条:事实不改变报价/承诺转接;交付措辞避免时限含义("faster delivery"不进卡) |
| **说话风格** | ✅ 规则化 | 1-2 句简短作答,禁营销腔;品牌/地名保留英文(GLM 按客户语言自然组织) |
| **extractor/evaluator** | ✅ 不受影响 | 两者用独立 prompt,不拼事实卡;extractor 只提取客户信息 |
| **token 成本** | ✅ 可忽略 | 实测草案 1289 chars ≈ 700-900 GLM token/轮(glm-4-flash 成本仍可忽略);换"事实常在零漏触发" |
| **回滚** | ✅ | 事实卡文件+2 行 loader,单 commit revert |
## 5. 任务分解
### Task 1: 事实卡文件 + loader 拼接(TDD)
**Files:**
- Create: `app/prompts/company_facts.md`(§3 草案,按 §7 确认结果定稿)
- Modify: `app/services/ai_service.py`(+3 行)
- Create: `tests/test_company_facts.py`
- [ ] **Step 1: 失败测试——prompt 组装顺序**
```python
"""公司事实卡: 拼接位置与墙安全性静态测试。"""
from pathlib import Path
from app.services.ai_service import AIService, COMPANY_FACTS, SYSTEM_PROMPT
FACTS_PATH = Path("app/prompts/company_facts.md")
class _CapturingProvider:
def __init__(self):
self.prompts = []
async def chat(self, messages, system_prompt):
self.prompts.append(system_prompt)
return "ok"
async def stream_chat(self, messages, system_prompt):
self.prompts.append(system_prompt)
for t in ("ok",):
yield t
def test_facts_concatenated_between_system_and_context():
# 顺序: 行为 prompt → 事实卡 → 当轮提醒(提醒必须最后,保持最新鲜)
svc = AIService(provider=_CapturingProvider())
svc.provider.prompts.clear()
import asyncio
asyncio.run(svc.chat([{"role": "user", "content": "hi"}], context="⚠️ 提醒"))
p = svc.provider.prompts[0]
assert "Hesu" in p # 事实在场
assert p.index(SYSTEM_PROMPT) < p.index(COMPANY_FACTS) < p.index("⚠️ 提醒")
def test_facts_absent_from_extractor_prompt():
# extractor/evaluator 不拼事实卡(它们有自己的 prompt,不该看到公司事实)
from app.services.ai_service import EVALUATOR_PROMPT, EXTRACTOR_PROMPT
assert "Hesu" not in EXTRACTOR_PROMPT
assert "Hesu" not in EVALUATOR_PROMPT
```
- [ ] **Step 2:** `python -m pytest tests/test_company_facts.py -v` → FAIL(两个测试均以 ImportError/收集错误失败——`COMPANY_FACTS` 是模块级常量且 company_facts.md 未建,属预期红灯形态;本文件仅此两测试,无部分通过预期被破坏)
- [ ] **Step 3: 实现**
`ai_service.py`:
```python
SYSTEM_PROMPT = _load_prompt("system.md")
COMPANY_FACTS = _load_prompt("company_facts.md") # 公司事实卡(知识与行为分离)
EXTRACTOR_PROMPT = _load_prompt("extractor.md")
EVALUATOR_PROMPT = _load_prompt("evaluator.md")
# 对话基座: 行为 prompt 在前(红线显著性),事实卡居中(参考资料),
# 当轮 context 提醒最后拼接(在 chat/stream_chat 内,保持最新鲜)
_BASE_CHAT_PROMPT = SYSTEM_PROMPT + "\n\n" + COMPANY_FACTS
```
`chat`/`stream_chat` 内 `SYSTEM_PROMPT + "\n\n" + context` 改为 `_BASE_CHAT_PROMPT + ("\n\n" + context if context else "")`。
创建 `app/prompts/company_facts.md`(§3 定稿内容)。
- [ ] **Step 4:** 测试通过;全量回归 `python -m pytest tests/ -q` 全绿(基线 160)
- [ ] **Step 5: 提交** `feat(kb): 公司事实卡入 prompt——Hesu 品牌/覆盖/网络零编造`
### Task 2: 事实卡墙安全静态测试(TDD)
- [ ] **Step 1: 失败测试**(追加到 `tests/test_company_facts.py`)
```python
from app.services.boundary_filter import (
no_contact_value, split_sentences, violates_boundary,
)
def _fact_sentences():
# 注: 事实卡是无句读的 markdown 列表,多行会合并成大句块——无害,
# 检测是 search 式,违规在任何块内都能命中(§3 草案独立评审实测 1289 chars
# 过两墙全清——长度数字随草案修订变化,以实施时实测为准)
complete, tail = split_sentences(COMPANY_FACTS)
return complete + ([tail] if tail else [])
def test_facts_pass_both_output_walls():
# AI 原样引用事实也必须安全: 每句过价格墙+零号码墙
# (陷阱实证: "五万"会被裸预算模式击杀、"累计出口五万台"→整句被替换成要号话术)
for s in _fact_sentences():
assert not violates_boundary(s), s[:60]
assert not no_contact_value(s), s[:60]
def test_facts_length_capped():
# 防营销手册化: 事实卡是卡片不是 brochure——超限=该精简或走检索方案
assert len(COMPANY_FACTS) < 2500
def test_facts_no_contact_channel():
# 我方联系方式绝不入卡(红线: web 指按钮/App 反向留资)
# 只查确定性标记: 邮箱@与 wa.me 链接("WhatsApp"品牌词是合法内容,不作标记)
assert "@" not in COMPANY_FACTS
assert "wa.me" not in COMPANY_FACTS.lower()
```
- [ ] **Step 2:** FAIL(文件未建/或内容违规)→ **Step 3:** 按 §3 定稿 → **Step 4:** 全绿
- [ ] **Step 5: 提交** `test(kb): 事实卡墙安全静态网——中文数字陷阱/联系方式/长度上限`
### Task 3: system.md 集成(人设定调 + 事实作答口径 + 编造面封堵)
- [ ] **改动 1——人设第一句**(业务定位用户已确认"新旧车都做")。⚠️ 该句是 system.md L1 的**前缀**(同行后续为"你说话像在 WhatsApp…"风格句)——做**子串替换**,不要整行替换删除风格句:
原(子串):
```
你是一位二手车外贸销售
```
改为:
```
你是一位汽车出口外贸销售(新车与二手车都做)
```
- [ ] **改动 2**——在【职能边界】"三类请求的处理"第 1 类(汽车信息)后追加一行:
```
- 公司情况(品牌/覆盖国家/出口经验/运作模式)按系统提供的事实卡简短作答建立信任——但不因此报价、承诺或跳过留资引导
```
- [ ] **改动 3——库存断言示例修正(三处,按文本锚点定位,勿信行号)**:示例在教学"现货断言",而 AI 无库存数据源(编造向量,见 §1 盘点)。保留各示例的原本教学意图(围绕客户说的车型/年份),只把现货断言改为"可采购调配"。⚠️ 行号会随文件改动漂移(方案撰写时实测在 L105/L111/L117,上轮修复后已漂移过一次)——实施时以 `grep -n "in stock\|tons of them" app/prompts/system.md` 定位,以下原文为准:
| 原文(逐字) | 改为 |
|---|---|
| "Corolla is a solid pick, we got tons of them. Any preference on year?" | "Corolla is a solid pick, we can source it easily. Any preference on year?" |
| "For 2025 models we got BYD Han, Toyota Camry, Honda Accord in stock. Budget range?" | "For 2025 models we can source BYD Han, Toyota Camry, Honda Accord. Budget range?" |
| "We got the 2024 Tang EV 7-seater and the DM-i AWD in stock. Want specs?" | "The 2024 Tang comes as the EV 7-seater and DM-i AWD — we can source either. Want specs?" |
⚠️ 差示例(L104 附近,"我们在非洲和东南亚市场有大量现车")是反面教材,**不要**顺手修改——它被标为"差"正是因为客服腔,保留其反例作用。
- [ ] **改动 4——红线区补三条**(价格墙对 warranty 形态白名单放行,保修承诺只能靠 prompt 封;Task 6 墙收紧为确定性兜底):
```
- 一切不清楚、没把握的问题(卡外国家/库存/保修/认证/任何事实细节)——不编造、不承诺、不否定、不断言,引导客户留联系方式,由客户经理沟通
- 不对库存/现车做断言——有没有现车、什么时候有,由客户经理确认;你只能说"可以采购调配"
- 不报任何保修/质保期限("X months warranty"这类数字承诺)——一律"客户经理会给你完整的保修方案"
```
- [ ] 回归 `python -m pytest tests/ -q` 全绿(prompt 改动无单测,靠既有安全网;注意 test_boundary_filter.py 的 `test_whitelist_clean` 用例含 "we got tons of them" 作为墙输入——那是测试自有字符串,不依赖 system.md,不受影响)
- [ ] **提交** `feat(prompt): 人设定调+事实作答口径+库存/保修编造面封堵`
### Task 4: 改动文档 + 冒烟清单
- [ ] 新建 `docs/2026-09-10-company-knowledge-base-changelog.md`(体例同前两份:背景/选型/文件清单/已知限制/部署回滚/冒烟结果)
- [ ] **真机 GLM 冒烟(部署前置项,单测覆盖不到 GLM 引用事实的方式)**:
| 场景 | 期望 |
|---|---|
| **"I'm in Ghana, do you have branch in Ghana?"(生产案例复现)** | **必须有分支**——"Yes, we've got a Ghana branch with bonded storage" + 顺势问需求;绝不再说 Not yet |
| "are you a real company?" | 1-2 句事实作答(首批出口企业/出口量/70+ 国),零编造零营销腔 |
| "do you have Zeeker? pickup trucks?" | 按卡答(品牌清单;皮卡重卡都有),不报库存承诺 |
| "do you ship to Kenya?"(卡内未列的国家) | **区分口径**:答"已出口到 70+ 国家,肯尼亚具体我让经理确认"——不承诺有网点、不说 Not yet |
| 中文问"你们公司在哪/怎么运作" | 中文简短作答,数字用出口量/70+ 形式(不能被价格墙拦) |
| "how much for Song L in Ghana?" | **公司事实不得软化价格红线**——仍迂回+要联系方式 |
| App 会话问公司情况 | 同样作答,零联系方式输出 |
| "are your cars new or used?" | 按确认口径作答:"新旧车都做"(事实卡+人设已定调) |
| "do you have Song L in stock right now?" | **不断言现货**——"可以采购调配"+顺势留资("留个 WhatsApp 经理告诉你现车情况")(改动 3 验证) |
| "is there warranty? how long?" | **不报期限数字**——"客户经理会给你完整保修方案"+留资引导(改动 4 + Task 6 墙级兜底:即使 GLM 硬说 "6 months warranty" 也会被价格墙替换) |
| "do you have office in Tokyo?"(卡外冷门事实) | **统一兜底**:不否定不肯定——"出口 70+ 国,东京这边我让经理确认,留个 WhatsApp" |
| 中文问"多久能到 Ghana?"/"质保多久?" | 不给时限/期限数字(中文单位形态 Task 6 已墙级兜底:"3天到货"/"6个月质保"会被替换) |
- [ ] **提交** `docs: 公司知识库——改动文档`
### Task 5: 再索要墙补裸碎片模式(Ghana 案例顺带发现,已实证)
**原理:** Ghana 案例的 AI 回复结尾 "Your number, please?" 经实证**穿透现有 REASK_PATTERNS**(只拦得住 "email or WhatsApp?" 组合形态;"Your number, please?"/"Your WhatsApp?" 裸碎片全漏)。已留资场景下这类碎片就是再索要。收紧版模式(动词后顾排除跟进确认句)。
⚠️ **既有测试同步更新(独立评审实证抓出,2026-09-10)**:新模式会命中 `test_asks_contact_clean_phrasings_pass` 里的 "Could you confirm your number?"(该断言编码 suspect 礼貌确认旧政策)。行为本身正确——`asks_contact` 仅在 confirmed(has_contact=True)时启用,此时再问"确认号码"就是冗余再索要;suspect 流(has_contact=False)闸门关着不受影响。按 Task 6 处理 test_warranty_clean 的同款方式:把该句从 clean 清单移入命中清单,注明政策依据与日期。**实施时必须对既有 asks_contact 全部测试句重跑核对**(首版"5 命中/5 放行"只验了自拟句集,漏了既有套件——教训)。
**Files:**
- Modify: `app/services/boundary_filter.py`(REASK_PATTERNS 追加一条)
- Test: `tests/test_boundary_filter.py`
- [ ] **Step 1: 失败测试 + 既有政策测试更新**(追加到 asks_contact 测试组;同步把 "Could you confirm your number?" 从 clean 测试移入命中测试,注释注明政策变更)
```python
def test_asks_contact_bare_fragment_asks():
# Ghana 生产案例: "Your number, please?" 曾穿透(5b 首版只覆盖动词/疑问词形态)
assert asks_contact("Your number, please?")
assert asks_contact("Your WhatsApp?")
assert asks_contact("So... your number?")
assert asks_contact("And your contact?")
# 政策变更(2026-09-10): confirmed 语境下"确认号码"也是冗余再索要
# (suspect 流闸门关着不受影响)——自 clean 清单移入
assert asks_contact("Could you confirm your number?")
def test_asks_contact_fragment_followup_still_pass():
# 动词后顾排除: 跟进确认(非索要)放行
assert not asks_contact("Did the manager reach your WhatsApp?")
assert not asks_contact("Got your number, thanks!")
assert not asks_contact("Our manager will send it to your WhatsApp shortly.")
```
- [ ] **Step 2:** FAIL → **Step 3: REASK_PATTERNS 追加**(模式已实证 5/5):
```python
# 裸碎片问句: "Your number, please?"/"Your WhatsApp?"(Ghana 案例穿透补)
# 后顾排除跟进确认句("reach/got/sent/did your X?" 是问进度不是要号)
re.compile(r"(?<!reach )(?<!reached )(?<!got )(?<!sent )(?<!did )"
r"your\s+(?:whatsapp|number|phone|e-?mail|contact)\b[^.!?]{0,20}[??]", re.I),
```
- [ ] **Step 4:** 全量回归 `python -m pytest tests/ -q` 全绿
- [ ] **Step 5: 提交** `fix(contact): 再索要墙补裸碎片形态——Your number, please?(Ghana案例)`
### Task 6: 时限/保修承诺墙级收紧(编造承诺从 prompt 层升为墙级确定性)
**原理(2026-09-10 实证):** 政策依据=用户定调"不清楚的不承诺":保修期限/交付时限是公司政策事实,AI 无真值,输出即编造。三处墙缺口(均已实证):
1. 价格墙时间模式的 warranty 豁免使 "we offer 6 months warranty" **确定性放行**(现墙 False)——直接去掉豁免即可(day/week/month 单位收紧后命中)。
2. ⚠️ **首版计划的测试 bug(实证抓出)**:`8 years warranty` 的 year 不在 `(day|week|month)` 内,去豁免后**仍放行**——且不能把 year 加进时间模式("under 5 years old" 二手车龄聊天会误杀)。修法:新增**定向模式** `\d+\s*years?\b[^.!?]{0,15}warranty`(英文年+质保)与 `\d+\s*年[^。!?]{0,8}(?:质保|保修)`(中文年+质保),"8 years warranty" ✓ 命中、"under 5 years old" ✓ 放行。
3. **中文时限/质保承诺今天全部零拦截**(时间模式只有英文单位):"3天到货"/"6个月质保"/"电池保修8个月" 现墙全 False。补 `\d+\s*(?:天|周|个月)`——实证 9/9 安全("2024年款"/"5座SUV"/"600km续航"/"2024 Tang"/"50,000+ 台"全放行;年 不收,防车型年款误杀;"一周"中文数字形态不收,观察)。
**Files:**
- Modify: `app/services/boundary_filter.py`(PRICE_PATTERNS)
- Test: `tests/test_boundary_filter.py`
- [ ] **Step 1: 失败测试 + 既有政策测试更新**
```python
def test_warranty_commitment_violates():
# 政策变更(2026-09-10 用户定调: 不清楚的留资转经理): AI 输出的保修期限
# 是编造的公司政策承诺——从"白名单放行"改为墙级违规
assert violates_boundary("we offer 6 months warranty on every car")
assert violates_boundary("battery has 8 years warranty") # year 不在时间模式内,靠定向模式
assert violates_boundary("warranty of 8 years on battery") # EN 倒装
assert violates_boundary("电池保修8个月")
assert violates_boundary("8年质保")
assert violates_boundary("整车质保3年10万公里") # CN 倒装(质保在前)
assert violates_boundary("质保3年或10万公里")
def test_chinese_time_commitment_violates():
# 既有缺口: 中文时限承诺此前零拦截(时间模式只有英文单位)
assert violates_boundary("3天到货")
assert violates_boundary("6个月质保")
def test_age_and_year_talk_still_clean():
# 误杀防护: 二手车龄/车型年款/无数字保修讨论必须放行(实证 13 条全清)
assert not violates_boundary("under 5 years old")
assert not violates_boundary("5 years old car")
assert not violates_boundary("2024年款")
assert not violates_boundary("5座SUV")
assert not violates_boundary("600km续航")
assert not violates_boundary("does it come with warranty?")
assert not violates_boundary("warranty details from our manager")
assert not violates_boundary("how many years warranty?") # 无承诺数字的反问
```
同步更新既有 `test_warranty_clean`(其断言 "6 months warranty on the engine" 干净编码的是**旧政策**,改为断言违规;保留无数字形态干净的断言),测试内注释注明政策变更依据与日期。
- [ ] **Step 2:** 新测试 FAIL(现墙放行)→ **Step 3: 实现**——PRICE_PATTERNS 时间模式替换 + 新增(模式集已实证 10 命中/13 放行):
```python
# 时间承诺: 去掉 warranty 豁免(政策变更 2026-09-10——保修期限是编造的
# 公司政策承诺,不再白名单)
re.compile(r"\d+\s*[- ]?(day|week|month)s?\b", re.I),
# 年限保修定向(双向): year 不能进时间模式(车龄 "under 5 years old" 会误杀),
# 只收 数字+年 与 warranty/质保/保修 的组合(含倒装"质保3年"/"warranty of 8 years")
re.compile(r"\d+\s*years?\b[^.!?]{0,15}warranty", re.I),
re.compile(r"warranty\s*(?:of|for|:)?\s*\d+\s*years?\b", re.I),
re.compile(r"\d+\s*年[^。!?]{0,8}(?:质保|保修)"),
re.compile(r"(?:质保|保修)[^。!?]{0,8}\d+\s*年"),
# 中文时限承诺: 天/周/个月(年不收——"2024年款" 是车型年款)
re.compile(r"\d+\s*(?:天|周|个月)"),
```
- [ ] **Step 4:** 全量回归 `python -m pytest tests/ -q` 全绿。前置安全检查: `grep -rn "个月\|[0-9]天\|months\|years" app/ tests/` 全目录核对模板/既有测试文案无 "N 天/N个月/N months/N years" 形态(独立评审已预核当前零命中,但检查范围必须全 tests/——防测试文案藏形态)
- [ ] **Step 5: 提交** `fix(contact): 时限/保修承诺墙级封堵——去warranty豁免+中文单位+年限定向(编造面盘点#2)`
## 6. 残余风险
1. **GLM 把事实用成长篇**——使用规则+风格约束双保险,冒烟场景 1 验证;失守再考虑事实卡减条
2. **事实过时无察觉**——KB 是静态文件,出口量/分支变化需人工更新;changelog 记"事实来源=2026-09 官网资料+用户口径确认"
3. **事实软化红线的间接路径**——如客户问"你们出口 70 国那运费多少",GLM 可能借势聊运费;冒烟专项验证
4. **车型公共参数编错(接受残余)**——GLM 内部知识答续航/电池/配置,新款(2026 款)可能过期失真;零防线,方向性通常正确,冒烟观察
5. **关税/税率百分比(观察项)**——"25% duty" 形态价格墙不拦,靠 tariff/关税触发词的提醒层兜底;冒烟观察,失守再议墙收紧
6. **库存断言为 prompt 层封堵(非墙级)**——Task 3 改动 3/4 是软约束;若冒烟仍出现 "in stock" 断言,升级方案:库存断言句式进再索要墙同款输出过滤。**保修/时限承诺已由 Task 6 升为墙级确定性封堵**(双向年限定向+中文单位,2026-09-10 实证 10 命中/13 放行)
7. token 常驻成本实测草案 1289 chars ≈ 700-900 GLM token/轮——glm-4-flash 可忽略;若未来换贵模型,再评估方案 C
8. **中文数字时限形态不收**("一周到货"/"三天发货")——中文数字+单位组合形态长尾大、误杀面难控,观察期;若冒烟出现,回流补模式(与"漏词回流补词"同机制)
9. **英文非保修年限表述不拦**("it lasted 8 years"类)——year 进时间模式会误杀二手车龄聊天,定向模式只收 warranty 邻接;边缘接受
10. **Task 5 误杀类(知情)**:confirmed 语境下引用/核对类问句("Has your number changed?"/"Could you confirm your email?")会被当再索要丢弃——丢句不掐断,损失一句可接受;suspect 流不受影响
11. **Task 6 时态不区分**:"N天前"过去式引用(中文)现在也会被墙替换——与英文 "3 days ago" 既有行为一致(此前中文恰好豁免),一致性修复而非回归
## 7. 待确认问题(执行前)
| # | 问题 | 影响 | 结论(全部确认于 2026-09-10) |
|---|---|---|---|
| 1 | ~~主业务:新车出口 / 二手车 / 两者?~~ | 人设第一句 + 事实卡一行 | ✅ **新旧车都做** |
| 2 | ~~累计出口量:About 写 50,000+,Key Facts 写 10,000+,矛盾~~ | 事实卡一行 | ✅ **50,000+** |
| 3 | ~~Venezuela 要不要列入覆盖?~~ | 事实卡一行 | ✅ **列入** |
| 4 | ~~"Leopard、ROX"两个品牌名是否准确?~~ | 品牌清单 | ✅ **保留原文**(Leopard、ROX;Zeeker 亦保留原文拼写,用户确认) |
确认方式:直接回复"1 新车出口 2 五万 3 不列 4 剔除"这类即可,或改任意项。
## 8. 部署与回滚
重启生效(prompt 启动加载);无迁移/配置/前端;六个任务各自成 commit,逐个可 revert。
File diff suppressed because it is too large Load Diff