diff --git a/plugins/catalog.json b/plugins/catalog.json index 5c3af68c80..c053a0e1e7 100644 --- a/plugins/catalog.json +++ b/plugins/catalog.json @@ -232,7 +232,7 @@ }, { "id": "langbot-team/LeadCapture", - "version": "0.2.0", + "version": "0.3.0", "category": "misc", "path": "misc/LeadCapture", "repository": "https://github.com/langbot-app/LangBot/tree/master/plugins/misc/LeadCapture" diff --git a/plugins/misc/LeadCapture/README.md b/plugins/misc/LeadCapture/README.md index 9d617ef331..c2f7f7773a 100644 --- a/plugins/misc/LeadCapture/README.md +++ b/plugins/misc/LeadCapture/README.md @@ -1,6 +1,6 @@ # Purchase Intent Lead Capture -Let your AI customer service notice customers who want to buy. After every AI reply, the plugin reads the conversation with a model you choose and against criteria you write, and decides whether the customer has **strong purchase intent**. If so, it appends a contact request to that reply, only once per customer. When the customer leaves a phone number, WeChat ID or email, the plugin sends the **contact details plus the entire conversation with that customer** to your inbox through [Resend](https://resend.com). +Let your AI customer service notice customers who want to buy. After every AI reply, the plugin reads the conversation with a model you choose and against criteria you write, **scores the customer's purchase intent 0–100** and flags **compliance risk**. At medium intent or above (default ≥ 30) it appends a contact request to that reply, only once per customer. When the customer leaves a phone number, WeChat ID or email, the plugin **extracts lead fields** (company, use case, volume, budget, …) from the whole conversation and sends them with the **contact details, score and the entire conversation** to your inbox through [Resend](https://resend.com). Works for private chats on any platform (WeChat Official Account, WeCom customer service, Telegram, QQ, …). @@ -38,9 +38,13 @@ Works for private chats on any platform (WeChat Official Account, WeCom customer | Setting | What to enter | | --- | --- | | Model for judging intent | A chat model. It is called once after every AI reply, so a fast, inexpensive model is best | -| Intent criteria (prompt) | In plain language, what counts as strong purchase intent for your business, e.g. asking about price, plans, contracts, invoices, trials or go-live dates counts; asking about features or small talk does not. A general default is provided | +| Scoring criteria (prompt) | Which signals add or subtract points; you can paste a keyword/score list. The model scores the whole conversation 0–100 by meaning (including Traditional Chinese and synonyms). A general default is provided | +| High-intent score | Default 60 | +| Score to ask for contact | Default 30: medium (30–59) and high intent are asked once; set it equal to the high-intent score to ask only high intent | +| Compliance risk criteria | Matching conversations (default: fake orders, ticket scalping, cracking, scraping WeChat/banks, VPNs, …) are flagged: **no contact request and no sales email**, listed separately on the page for review | +| Lead fields to extract | Filled from the whole conversation once contact details arrive (default: company, industry, use case, target sites, monthly volume, budget, go-live date, contact name, title, work email, WeChat/phone, current vendor); shown at the top of the email and on the page | | Messages used for judging | How many recent messages to read, default 20, max 100 | -| Contact request text | Fixed text appended to the AI reply when strong intent is detected | +| Contact request text | Fixed text appended to the AI reply once the ask score is reached | | Thank-you text | Appended to the next AI reply after contact details arrive; leave empty to disable | | Resend API key | The `re_…` key from step 1 | | Sender address | An address on your verified domain, e.g. `leads@example.com` or `Leads ` | diff --git a/plugins/misc/LeadCapture/components/pages/console/console.py b/plugins/misc/LeadCapture/components/pages/console/console.py index 764b6157a1..eec96ccb4e 100644 --- a/plugins/misc/LeadCapture/components/pages/console/console.py +++ b/plugins/misc/LeadCapture/components/pages/console/console.py @@ -23,6 +23,9 @@ def summarize(row: dict) -> dict: "last_judged_at": last_judge.get("at"), "last_strong": last_judge.get("strong"), "last_reason": last_judge.get("reason", ""), + "score": row.get("score", last_judge.get("score")), + "level": row.get("level", last_judge.get("level", "")), + "fields": row.get("fields") or {}, "reason": row.get("reason", ""), "contact": row.get("contact", ""), "email_status": row.get("email_status", ""), @@ -43,6 +46,7 @@ async def handle_api(self, request: PageRequest) -> PageResponse: totals = { "sessions": len(rows), "asked": sum(1 for row in rows if row["status"] == "asked"), + "risk": sum(1 for row in rows if row["status"] == "risk"), "captured": sum(1 for row in rows if row["status"] == "captured"), "emailed": sum(1 for row in rows if row["email_status"] == "sent"), "judgments": sum(row["judgments"] for row in rows), diff --git a/plugins/misc/LeadCapture/components/pages/console/i18n/en_US.json b/plugins/misc/LeadCapture/components/pages/console/i18n/en_US.json index 35956e37b2..0dbbe25d22 100644 --- a/plugins/misc/LeadCapture/components/pages/console/i18n/en_US.json +++ b/plugins/misc/LeadCapture/components/pages/console/i18n/en_US.json @@ -37,5 +37,16 @@ "timeline": "Timeline (newest first)", "noEvents": "No events recorded.", "recentConversation": "Recent conversation (UTC)", - "conversationUnavailable": "Conversation unavailable" + "conversationUnavailable": "Conversation unavailable", + "riskCount": "Compliance risk", + "filterRisk": "Compliance risk", + "colScore": "Score", + "statusRisk": "Compliance risk", + "levelHigh": "High", + "levelMedium": "Medium", + "levelLow": "Low", + "eventRisk": "Flagged for compliance review; no contact request", + "eventFields": "Lead fields extracted", + "leadFields": "Lead fields", + "notMentioned": "Not mentioned" } \ No newline at end of file diff --git a/plugins/misc/LeadCapture/components/pages/console/i18n/zh_Hans.json b/plugins/misc/LeadCapture/components/pages/console/i18n/zh_Hans.json index f21108daef..c55d132d59 100644 --- a/plugins/misc/LeadCapture/components/pages/console/i18n/zh_Hans.json +++ b/plugins/misc/LeadCapture/components/pages/console/i18n/zh_Hans.json @@ -37,5 +37,16 @@ "timeline": "判断时间线(最新在上)", "noEvents": "暂无记录。", "recentConversation": "最近对话(UTC)", - "conversationUnavailable": "对话记录不可用" + "conversationUnavailable": "对话记录不可用", + "riskCount": "合规风险", + "filterRisk": "合规风险", + "colScore": "意向评分", + "statusRisk": "合规风险", + "levelHigh": "高意向", + "levelMedium": "中意向", + "levelLow": "低意向", + "eventRisk": "标记为合规风险:不请求联系方式、不发销售邮件", + "eventFields": "已提取线索字段", + "leadFields": "线索信息", + "notMentioned": "未提及" } \ No newline at end of file diff --git a/plugins/misc/LeadCapture/components/pages/console/index.html b/plugins/misc/LeadCapture/components/pages/console/index.html index d5de6fdda2..db84e755e3 100644 --- a/plugins/misc/LeadCapture/components/pages/console/index.html +++ b/plugins/misc/LeadCapture/components/pages/console/index.html @@ -45,6 +45,15 @@ .b-idle { background: rgba(107,114,128,.15); } .b-asked { background: rgba(245,158,11,.18); color: #b45309; } .b-captured { background: rgba(16,185,129,.18); color: #047857; } + .b-risk { background: rgba(239,68,68,.15); color: #b91c1c; } + .b-high { background: rgba(239,68,68,.12); color: #b91c1c; } + .b-medium { background: rgba(245,158,11,.18); color: #b45309; } + .b-low { background: rgba(107,114,128,.15); } + .score { font-weight: 600; font-size: 15px; } + .signals { margin-top: 4px; } + .signals span { display: inline-block; margin: 2px 4px 0 0; padding: 0 6px; border-radius: 6px; font-size: 12px; border: 1px solid var(--langbot-border, #e5e7eb); } + .fields { width: 100%; font-size: 13px; } + .fields td { padding: 4px 6px; border-bottom: 1px solid var(--langbot-border, #e5e7eb); } .b-strong { background: rgba(245,158,11,.18); color: #b45309; } .b-weak { background: rgba(107,114,128,.15); } .b-error { background: rgba(239,68,68,.15); color: #b91c1c; } @@ -79,6 +88,7 @@

Lead judgments

Asked for contact
-
Contact captured
-
Emailed
-
+
Compliance risk
-
@@ -86,6 +96,7 @@

Lead judgments

+ @@ -98,6 +109,7 @@

Lead judgments

User Status + Score Latest judgment Judged (strong) Contact / email @@ -128,7 +140,7 @@

Lead judgments

return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()); } function statusBadge(status) { - var label = { idle: t('statusIdle', 'Not yet'), asked: t('statusAsked', 'Asked'), captured: t('statusCaptured', 'Captured') }[status] || status; + var label = { idle: t('statusIdle', 'Not yet'), asked: t('statusAsked', 'Asked'), captured: t('statusCaptured', 'Captured'), risk: t('statusRisk', 'Compliance risk') }[status] || status; return '' + esc(label) + ''; } function judgeBadge(strong) { @@ -136,6 +148,14 @@

Lead judgments

if (strong === false) return '' + esc(t('weak', 'Not strong')) + ''; return '-'; } + function scoreCell(score, level) { + if (score === null || score === undefined) return '-'; + var label = { high: t('levelHigh', 'High'), medium: t('levelMedium', 'Medium'), low: t('levelLow', 'Low') }[level] || ''; + return '' + esc(score) + ' ' + (label ? '' + esc(label) + '' : ''); + } + function signalsHtml(signals) { + return (signals && signals.length) ? '
' + signals.map(function (x) { return '' + esc(x) + ''; }).join('') + '
' : ''; + } function emailBadge(status) { if (!status) return ''; if (status === 'sent') return '' + esc(t('emailSent', 'Email sent')) + ''; @@ -146,7 +166,7 @@

Lead judgments

var q = state.query.toLowerCase(); return state.sessions.filter(function (s) { if (state.filter === 'errors' && !s.errors && !(s.email_status && s.email_status !== 'sent')) return false; - if (['idle', 'asked', 'captured'].indexOf(state.filter) >= 0 && s.status !== state.filter) return false; + if (['idle', 'asked', 'captured', 'risk'].indexOf(state.filter) >= 0 && s.status !== state.filter) return false; if (q && (s.user + ' ' + (s.contact || '')).toLowerCase().indexOf(q) < 0) return false; return true; }); @@ -159,6 +179,7 @@

Lead judgments

return '' + '' + esc(s.user) + '' + '' + statusBadge(s.status) + '' + + '' + scoreCell(s.score, s.level) + '' + '' + judgeBadge(s.last_strong) + '
' + esc(s.last_reason || s.reason || '') + '
' + '' + s.judgments + ' (' + s.strong_judgments + ')' + (s.errors ? ' ' + s.errors + ' ' + esc(t('errorsShort', 'errors')) + '' : '') + '' + '' + esc(s.contact || '') + '
' + emailBadge(s.email_status) + '
' + @@ -171,16 +192,18 @@

Lead judgments

} function renderTotals(totals) { - ['sessions', 'judgments', 'asked', 'captured', 'emailed'].forEach(function (k) { - document.getElementById('t-' + k).textContent = totals[k]; + ['sessions', 'judgments', 'asked', 'captured', 'emailed', 'risk'].forEach(function (k) { + document.getElementById('t-' + k).textContent = totals[k] == null ? 0 : totals[k]; }); } function renderEvent(e) { var cls = 'event', body = ''; if (e.kind === 'judge') { - if (e.strong) cls += ' strong'; - body = judgeBadge(e.strong) + ' ' + esc(e.reason || '') + + if (e.risk) cls += ' error'; else if (e.strong) cls += ' strong'; + body = (e.score !== undefined ? scoreCell(e.score, e.level) + ' ' : judgeBadge(e.strong) + ' ') + esc(e.reason || '') + + (e.risk ? '
' + esc(t('statusRisk', 'Compliance risk')) + ' ' + esc(e.risk_reason || '') + '
' : '') + + signalsHtml(e.signals) + (e.message ? '
' + esc(t('userSaid', 'User')) + ': ' + esc(e.message) + '
' : '') + (e.reply ? '
AI: ' + esc(e.reply) + '
' : ''); } else if (e.kind === 'asked') { @@ -190,6 +213,12 @@

Lead judgments

cls += ' captured'; body = '' + esc(t('eventContact', 'Contact captured')) + ': ' + esc(e.contact) + (e.message ? '
' + esc(e.message) + '
' : ''); + } else if (e.kind === 'risk') { + cls += ' error'; + body = '' + esc(t('eventRisk', 'Flagged for compliance review; no contact request')) + ' ' + esc(e.reason || ''); + } else if (e.kind === 'fields') { + cls += ' captured'; + body = '' + esc(t('eventFields', 'Lead fields extracted')) + ''; } else if (e.kind === 'email') { cls += e.status === 'sent' ? ' captured' : ' error'; body = emailBadge(e.status) + ' #' + esc(e.attempt) + ''; @@ -210,9 +239,14 @@

Lead judgments

'
' + '' + esc(t('colUser', 'User')) + '' + esc(s.user) + '' + '' + esc(t('colStatus', 'Status')) + '' + statusBadge(s.status) + '' + + '' + esc(t('colScore', 'Score')) + '' + scoreCell(s.score, s.level) + '' + (s.reason ? '' + esc(t('reason', 'Reason')) + '' + esc(s.reason) + '' : '') + (s.contact ? '' + esc(t('colContact', 'Contact')) + '' + esc(s.contact) + ' ' + emailBadge(s.email_status) + '' : '') + '
' + + (Object.keys(s.fields || {}).length ? '

' + esc(t('leadFields', 'Lead fields')) + '

' + + Object.keys(s.fields).map(function (k) { + return ''; + }).join('') + '
' + esc(k) + '' + (s.fields[k] ? esc(s.fields[k]) : '' + esc(t('notMentioned', 'Not mentioned')) + '') + '
' : '') + '

' + esc(t('timeline', 'Timeline (newest first)')) + '

' + (events.length ? events.map(renderEvent).join('') : '
' + esc(t('noEvents', 'No events recorded.')) + '
') + '

' + esc(t('recentConversation', 'Recent conversation (UTC)')) + '

'; diff --git a/plugins/misc/LeadCapture/main.py b/plugins/misc/LeadCapture/main.py index c7f5f249b4..526293e96a 100644 --- a/plugins/misc/LeadCapture/main.py +++ b/plugins/misc/LeadCapture/main.py @@ -22,10 +22,15 @@ RESEND_URL = "https://api.resend.com/emails" DEFAULT_INTENT_PROMPT = ( - "用户明确表达了购买、下单、试用、报价、签约、采购或与销售沟通的意愿," - "或在询问价格、套餐、付款方式、发票、合同、交付时间等成交相关细节,视为强烈购买意愿。" - "只是了解产品功能、闲聊或技术咨询,不算强烈购买意愿。" + "询问价格、套餐、报价、购买、付款方式、发票、合同、采购流程,或提出企业级、大规模、长期合作需求,属于强购买信号(高分);" + "申请试用、测试、演示、询问如何接入、主动要求联系销售,或说明了具体产品需求和使用场景,属于中等信号;" + "时间紧迫、表明决策人身份或提供公司信息可以加分;只是了解功能、学习研究、个人使用、闲聊,或想要免费资源,应当低分。" ) +DEFAULT_RISK_PROMPT = "刷单刷量、薅羊毛、抢票秒杀、破解盗号、爬取微信或银行等敏感数据、翻墙或 VPN 等明显违规或高风险用途。" +DEFAULT_LEAD_FIELDS = [ + "公司名称", "行业", "使用场景", "目标网站或数据来源", "预估月请求量或数据量", + "预算范围", "上线时间", "联系人姓名", "职位", "企业邮箱", "微信或电话", "现有供应商", +] DEFAULT_ASK_TEXT = "为了更好地为您服务,方便留下您的手机号或微信号吗?我们的顾问会尽快与您联系。" DEFAULT_THANKS_TEXT = "已收到您的联系方式,我们的顾问会尽快与您联系,感谢您的信任!" MAX_EMAIL_ATTEMPTS = 3 @@ -118,6 +123,38 @@ def response_text(response) -> str: return "" +def to_score(value) -> int: + try: + return max(0, min(100, int(round(float(value))))) + except (TypeError, ValueError): + return 0 + + +def to_bool(value) -> bool: + if isinstance(value, str): + return value.strip().lower() in ("true", "yes", "1", "是") + return bool(value) + + +def int_config(config: dict, name: str, default: int) -> int: + try: + return int(config.get(name, default)) + except (TypeError, ValueError): + return default + + +def score_level(config: dict, score: int) -> str: + """high / medium / low by the configured thresholds.""" + high = int_config(config, "high_score", 60) + ask = int_config(config, "ask_score", 30) + if score >= high: + return "high" + return "medium" if score >= ask else "low" + + +LEVEL_LABELS = {"high": "高意向", "medium": "中意向", "low": "低意向"} + + def role_label(role: str) -> str: return "用户" if role == "user" else "AI" @@ -228,18 +265,56 @@ async def ask_llm(self, model: str, system: str, user_text: str) -> dict: ) return parse_json_object(response_text(response)) - async def evaluate_intent(self, config: dict, dialogue: list[str]) -> tuple[bool, str]: + async def evaluate_intent(self, config: dict, dialogue: list[str]) -> dict: + """Score purchase intent 0–100 and flag compliance risk; returns a normalized judgment.""" criteria = str(config.get("intent_prompt") or DEFAULT_INTENT_PROMPT).strip() + risk = str(config.get("risk_prompt") or DEFAULT_RISK_PROMPT).strip() system = ( - "你是销售线索分析助手。请根据「判断标准」阅读客户与 AI 客服的对话,判断客户当前是否有强烈购买意愿。\n" - "只输出一个 JSON 对象,不要输出其他内容:{\"strong_intent\": true 或 false, \"reason\": \"一句话理由\"}\n\n" - f"判断标准:\n{criteria}" + "你是销售线索分析助手。请阅读客户与 AI 客服的对话,按「评分标准」综合整段对话给客户当前的购买意向打 0–100 分" + "(按语义理解,包括繁体、同义和口语化说法),并判断是否涉及「合规风险」。\n" + "只输出一个 JSON 对象,不要输出其他内容:\n" + "{\"score\": 0 到 100 的整数, \"signals\": [\"命中的主要信号,简短\"], " + "\"risk\": true 或 false, \"risk_reason\": \"涉及合规风险时说明原因,否则为空\", \"reason\": \"一句话说明打分理由\"}\n\n" + f"评分标准:\n{criteria}\n\n合规风险:\n{risk}" ) result = await self.ask_llm(str(config["model"]), system, "对话记录(从旧到新):\n" + "\n".join(dialogue)) - strong = result.get("strong_intent") - if isinstance(strong, str): - strong = strong.strip().lower() in ("true", "yes", "1", "是") - return bool(strong), str(result.get("reason") or "").strip() + if "score" in result: + score = to_score(result.get("score")) + elif "strong_intent" in result: + # Tolerate the 0.1/0.2 output shape from models that ignore the new schema. + score = int_config(config, "high_score", 60) if to_bool(result.get("strong_intent")) else 0 + else: + raise ValueError("judgment has no score") + signals = result.get("signals") or [] + if not isinstance(signals, list): + signals = [str(signals)] + return { + "score": score, + "level": score_level(config, score), + "signals": [excerpt(str(item))[:60] for item in signals][:10], + "risk": to_bool(result.get("risk")), + "risk_reason": str(result.get("risk_reason") or "").strip(), + "reason": str(result.get("reason") or "").strip(), + } + + async def extract_fields(self, config: dict, history: list[dict]) -> dict: + """Pull the configured lead fields out of the whole conversation ({} on failure).""" + fields = [str(name).strip() for name in (config.get("lead_fields") or DEFAULT_LEAD_FIELDS) if str(name).strip()] + if not fields: + return {} + transcript = "\n".join( + f"{role_label(row.get('role'))}: {stored_text(row.get('message_content'))}" for row in history[-200:] + ) + system = ( + "从客户与客服的完整对话中提取销售线索信息。只根据客户明确说过的内容填写,不要推测或编造;没有提到的填空字符串。\n" + "只输出一个 JSON 对象,键必须严格是:" + json.dumps(fields, ensure_ascii=False) + ) + try: + result = await self.ask_llm(str(config["model"]), system, transcript) + except Exception as error: + logger.warning("LeadCapture field extraction failed: %s", type(error).__name__) + return {} + return {name: excerpt(str(result.get(name) or "").strip()) for name in fields} async def extract_contact(self, config: dict, text: str) -> str: contact = find_contact(text) @@ -266,11 +341,15 @@ def build_email(self, config: dict, lead: dict, history: list[dict]) -> dict: f"[{utc_label(row.get('timestamp'))} UTC] {role_label(row.get('role'))}: {stored_text(row.get('message_content'))}" for row in history ] + level = LEVEL_LABELS.get(lead.get("level", ""), "") summary = [ ("联系方式", lead["contact"]), + ("意向评分", f"{lead['score']} 分({level})" if lead.get("score") is not None else "-"), + ("命中信号", "、".join(lead.get("signals") or []) or "-"), + ("意向判断理由", lead.get("reason") or "-"), + *[(name, value or "未提及") for name, value in (lead.get("fields") or {}).items()], ("用户 ID", lead["user"]), ("来源 Bot", bot_name), - ("意向判断理由", lead.get("reason") or "-"), ("留资时间 (UTC)", utc_label(datetime.fromtimestamp(lead["captured_at"], UTC).isoformat())), ("首次联系 (UTC)", first), ("消息总数", str(len(history))), @@ -294,7 +373,10 @@ def build_email(self, config: dict, lead: dict, history: list[dict]) -> dict: return { "from": str(config.get("from_email") or "").strip(), "to": [address.strip() for address in config.get("to_emails", []) if str(address).strip()], - "subject": f"【购买意向】{lead['contact']} · {bot_name}", + "subject": ( + f"【购买意向·{level} {lead['score']}分】{lead['contact']} · {bot_name}" + if lead.get("score") is not None else f"【购买意向】{lead['contact']} · {bot_name}" + ), "text": text_body, "html": html_body, "attachments": [{ @@ -333,6 +415,10 @@ async def deliver(self, config: dict, lead: dict) -> None: record(lead, "email", status=lead["email_status"], attempt=lead["email_attempts"]) logger.warning("LeadCapture could not read the conversation: %s", lead["email_status"]) return + if "fields" not in lead: + lead["fields"] = await self.extract_fields(config, history) + if lead["fields"]: + record(lead, "fields", fields=lead["fields"]) error = await self.send_email(config, self.build_email(config, lead, history)) lead["email_status"] = error or "sent" record(lead, "email", status=lead["email_status"], attempt=lead["email_attempts"], recipients=len( @@ -390,8 +476,8 @@ async def on_responded(self, event_context) -> None: self.append_to_reply(event_context, str(config.get("thanks_text", DEFAULT_THANKS_TEXT) or "")) await self.save(key, lead) return - if lead and lead.get("status") == "asked": - return # Already asked once; never ask again. + if lead and lead.get("status") in ("asked", "risk"): + return # Asked once already, or flagged for compliance review: never ask (again). lead = lead or {"status": "idle", "bot": bot, "user": user, "created_at": time.time()} try: history = int(config.get("history_messages", 20)) @@ -407,16 +493,21 @@ async def on_responded(self, event_context) -> None: user_text = next((line[len("用户: "):] for line in reversed(dialogue) if line.startswith("用户: ")), "") dialogue.append(f"AI: {event.response_text}") try: - strong, reason = await self.evaluate_intent(config, dialogue) + judgment = await self.evaluate_intent(config, dialogue) except Exception as error: logger.warning("LeadCapture intent evaluation failed: %s", type(error).__name__) record(lead, "error", stage="judge", error=type(error).__name__, message=excerpt(user_text)) await self.save(key, lead) return - record(lead, "judge", strong=strong, reason=reason, message=excerpt(user_text), - reply=excerpt(event.response_text)) - if strong: - lead.update(status="asked", reason=reason, asked_at=time.time()) + strong = judgment["level"] in ("high", "medium") and not judgment["risk"] + record(lead, "judge", strong=strong, message=excerpt(user_text), reply=excerpt(event.response_text), + **judgment) + lead.update(score=judgment["score"], level=judgment["level"]) + if judgment["risk"]: + lead.update(status="risk", reason=judgment["risk_reason"] or judgment["reason"], risk_at=time.time()) + record(lead, "risk", reason=lead["reason"]) + elif strong: + lead.update(status="asked", reason=judgment["reason"], signals=judgment["signals"], asked_at=time.time()) record(lead, "asked", text=excerpt(str(config.get("ask_text") or DEFAULT_ASK_TEXT))) self.append_to_reply(event_context, str(config.get("ask_text") or DEFAULT_ASK_TEXT)) await self.save(key, lead) diff --git a/plugins/misc/LeadCapture/manifest.yaml b/plugins/misc/LeadCapture/manifest.yaml index c13fcadd75..67ba77515d 100644 --- a/plugins/misc/LeadCapture/manifest.yaml +++ b/plugins/misc/LeadCapture/manifest.yaml @@ -4,7 +4,7 @@ metadata: author: langbot-team name: LeadCapture repository: https://github.com/langbot-app/LangBot/tree/master/plugins/misc/LeadCapture - version: 0.2.0 + version: 0.3.0 description: en_US: "Let an LLM spot customers with strong purchase intent, ask them once for contact details, and email the lead with the full conversation via Resend." zh_Hans: "由 LLM 根据对话和提示词判断用户是否有强烈购买意愿,只请求一次联系方式,并通过 Resend 把线索和完整对话发到指定邮箱。" @@ -41,13 +41,40 @@ spec: zh_Hans: 用来判断购买意愿,以及在规则识别不到时识别联系方式。 - name: intent_prompt type: text - default: "用户明确表达了购买、下单、试用、报价、签约、采购或与销售沟通的意愿,或在询问价格、套餐、付款方式、发票、合同、交付时间等成交相关细节,视为强烈购买意愿。只是了解产品功能、闲聊或技术咨询,不算强烈购买意愿。" + default: "询问价格、套餐、报价、购买、付款方式、发票、合同、采购流程,或提出企业级、大规模、长期合作需求,属于强购买信号(高分);申请试用、测试、演示、询问如何接入、主动要求联系销售,或说明了具体产品需求和使用场景,属于中等信号;时间紧迫、表明决策人身份或提供公司信息可以加分;只是了解功能、学习研究、个人使用、闲聊,或想要免费资源,应当低分。" label: - en_US: Intent criteria (prompt) - zh_Hans: 购买意愿判断标准(提示词) + en_US: Scoring criteria (prompt) + zh_Hans: 购买意向评分标准(提示词) description: - en_US: Describe what counts as strong purchase intent for your business. The plugin adds the conversation and asks for a yes/no answer. - zh_Hans: 描述在你们业务里什么算强烈购买意愿。插件会附上对话并要求模型给出是/否结论。 + en_US: Describe which signals add or subtract points for your business (you can paste a keyword/score list). The model scores the conversation 0–100. + zh_Hans: 描述在你们业务里哪些信号加分、哪些扣分(可以直接粘贴关键词和分值清单)。模型会据此给整段对话打 0–100 分。 + - name: high_score + type: integer + default: 60 + label: + en_US: High-intent score + zh_Hans: 高意向分数线 + description: + en_US: Scores at or above this are high intent. + zh_Hans: 达到这个分数视为高意向。 + - name: ask_score + type: integer + default: 30 + label: + en_US: Score to ask for contact + zh_Hans: 请求联系方式的分数线 + description: + en_US: Scores at or above this (medium and high intent) get the contact request once. Set it equal to the high-intent score to ask only high-intent users. + zh_Hans: 达到这个分数(中意向和高意向)就请求一次联系方式。设成和高意向分数线相同,则只问高意向用户。 + - name: risk_prompt + type: text + default: "刷单刷量、薅羊毛、抢票秒杀、破解盗号、爬取微信或银行等敏感数据、翻墙或 VPN 等明显违规或高风险用途。" + label: + en_US: Compliance risk criteria + zh_Hans: 合规风险判定 + description: + en_US: Conversations matching these are flagged for review; the plugin never asks them for contact details or emails sales. + zh_Hans: 命中这些情形的会话会被标记为合规风险:不请求联系方式、不给销售发邮件,在页面上单独标出以便人工审核。 - name: history_messages type: integer default: 20 @@ -75,6 +102,27 @@ spec: description: en_US: Appended to the next AI reply after contact details are received. Leave empty to disable. zh_Hans: 收到联系方式后附加在下一次 AI 回复后面。留空则不发送。 + - name: lead_fields + type: array[string] + default: + - 公司名称 + - 行业 + - 使用场景 + - 目标网站或数据来源 + - 预估月请求量或数据量 + - 预算范围 + - 上线时间 + - 联系人姓名 + - 职位 + - 企业邮箱 + - 微信或电话 + - 现有供应商 + label: + en_US: Lead fields to extract + zh_Hans: 需要提取的线索字段 + description: + en_US: After contact details arrive, the model fills these from the whole conversation; they appear at the top of the email and on the page. + zh_Hans: 拿到联系方式后,模型从整段对话中提取这些信息,放在邮件开头和页面详情里;没提到的显示「未提及」。 - name: resend_api_key type: secret required: true diff --git a/plugins/misc/LeadCapture/readme/README_zh_Hans.md b/plugins/misc/LeadCapture/readme/README_zh_Hans.md index f47268e96a..b601de7984 100644 --- a/plugins/misc/LeadCapture/readme/README_zh_Hans.md +++ b/plugins/misc/LeadCapture/readme/README_zh_Hans.md @@ -1,13 +1,13 @@ # 购买意向留资 -让 AI 客服自动发现「想买」的客户:插件在每次 AI 回复后,用你选定的模型、按你写的判断标准阅读对话,判断客户是否有**强烈购买意愿**。一旦判定为是,就在这次回复后面附上一句请求联系方式的话术(每个客户只问一次);客户留下手机号 / 微信 / 邮箱后,插件把**联系方式 + 与该客户的全部对话记录**通过 [Resend](https://resend.com) 发到你指定的邮箱。 +让 AI 客服自动发现「想买」的客户:插件在每次 AI 回复后,用你选定的模型、按你写的评分标准给客户的**购买意向打 0–100 分**,并识别**合规风险**。达到中意向(默认 ≥ 30 分)就在这次回复后面附上一句请求联系方式的话术(每个客户只问一次);客户留下手机号 / 微信 / 邮箱后,插件从整段对话中**提取公司、场景、用量、预算等线索字段**,连同**联系方式、评分和全部对话记录**通过 [Resend](https://resend.com) 发到你指定的邮箱。 适用于任意平台的私聊(微信公众号、企业微信客服、Telegram、QQ……)。 ## 效果一览 1. 客户和 AI 正常聊天,例如问「年付多少钱?能开发票吗?」 -2. AI 回复后,插件判断为强烈购买意愿,这次回复末尾多一句:「为了更好地为您服务,方便留下您的手机号或微信号吗?我们的顾问会尽快与您联系。」 +2. AI 回复后,插件给出评分(例如 75 分 · 高意向,命中「询价」「发票」);达到请求分数线时,这次回复末尾多一句:「为了更好地为您服务,方便留下您的手机号或微信号吗?我们的顾问会尽快与您联系。」 3. 客户回复「138 0013 8000」。插件识别到联系方式,立刻发出一封邮件: - 标题:`【购买意向】138 0013 8000 · 微信公众号` - 正文:联系方式、用户 ID、来源 Bot、意向判断理由、留资时间、首次联系时间、消息总数,以及完整对话; @@ -40,9 +40,13 @@ | 配置项 | 填什么 | | --- | --- | | 判断用的模型 | 选一个对话模型。每次 AI 回复后都会调用一次,建议选响应快、成本低的模型 | -| 购买意愿判断标准(提示词) | 用自然语言描述在你们业务里什么算强烈购买意愿,例如「询问价格、套餐、合同、发票、试用、上线时间」算,「只问功能、闲聊」不算。已有一份通用默认值 | +| 购买意向评分标准(提示词) | 描述哪些信号加分、哪些扣分,可以直接粘贴你们的「关键词 + 分值」清单。模型按语义理解(含繁体、同义说法)给整段对话打 0–100 分。已有一份通用默认值 | +| 高意向分数线 | 默认 60,达到即为高意向 | +| 请求联系方式的分数线 | 默认 30:中意向(30–59)和高意向都会被请求一次联系方式;设成和高意向分数线相同则只问高意向 | +| 合规风险判定 | 命中这些情形(默认:刷单、抢票、破解、爬微信/银行、翻墙/VPN 等)的会话标记为「合规风险」:**不请求联系方式、不发销售邮件**,在页面中单独列出供人工审核 | +| 需要提取的线索字段 | 留资后从整段对话里提取的字段,默认:公司名称、行业、使用场景、目标网站或数据来源、预估月请求量或数据量、预算范围、上线时间、联系人姓名、职位、企业邮箱、微信或电话、现有供应商。放在邮件开头和页面详情中,没提到的显示「未提及」 | | 参与判断的对话条数 | 每次判断读取最近多少条对话,默认 20,最多 100 | -| 请求联系方式的话术 | 判定为强烈意愿时附加在 AI 回复后的固定文案 | +| 请求联系方式的话术 | 达到请求分数线时附加在 AI 回复后的固定文案 | | 收到联系方式后的致谢 | 留资后附加在下一次 AI 回复后的文案,留空则不发 | | Resend API Key | 第 1 步的 `re_` 开头的 Key | | 发件地址 | 已验证域名下的地址,例如 `leads@example.com`,也可以写成 `销售线索 ` | diff --git a/plugins/misc/LeadCapture/tests/test_lead_capture.py b/plugins/misc/LeadCapture/tests/test_lead_capture.py index e8d5391a1e..f039c1eb97 100644 --- a/plugins/misc/LeadCapture/tests/test_lead_capture.py +++ b/plugins/misc/LeadCapture/tests/test_lead_capture.py @@ -202,7 +202,8 @@ async def test_contact_capture_emails_full_history_and_thanks_once(monkeypatch): await fire(handlers, events.NormalMessageResponded, after) later = responded("Bye.") await fire(handlers, events.NormalMessageResponded, later) - assert len(host.llm_calls) == 1 # contact found by pattern, no extraction call + # One judgment, then field extraction; the contact came from patterns, so no contact-extraction call. + assert len(host.llm_calls) == 2 assert len(resend.requests) == 1 request = resend.requests[0] assert request.headers["Authorization"] == "Bearer re_secret_key" @@ -315,12 +316,15 @@ def test_manifest(): assert manifest["spec"]["permissions"] == { "capabilities": ["models.invoke", "storage.plugin"], "workspace_operations": ["monitoring.messages"], } - assert manifest["metadata"]["version"] == "0.2.0" + assert manifest["metadata"]["version"] == "0.3.0" assert manifest["spec"]["components"]["Page"]["fromDirs"][0]["path"] == "components/pages/" names = [item["name"] for item in manifest["spec"]["config"]] - assert names == ["model", "intent_prompt", "history_messages", "ask_text", "thanks_text", - "resend_api_key", "from_email", "to_emails"] + assert names == ["model", "intent_prompt", "high_score", "ask_score", "risk_prompt", "history_messages", + "ask_text", "thanks_text", "lead_fields", "resend_api_key", "from_email", "to_emails"] defaults = {item["name"]: item.get("default") for item in manifest["spec"]["config"]} + assert defaults["risk_prompt"] == lead_main.DEFAULT_RISK_PROMPT + assert defaults["lead_fields"] == lead_main.DEFAULT_LEAD_FIELDS + assert (defaults["high_score"], defaults["ask_score"]) == (60, 30) assert defaults["intent_prompt"] == lead_main.DEFAULT_INTENT_PROMPT assert defaults["ask_text"] == lead_main.DEFAULT_ASK_TEXT assert defaults["thanks_text"] == lead_main.DEFAULT_THANKS_TEXT @@ -352,6 +356,8 @@ async def test_page_lists_sessions_and_shows_timeline(monkeypatch): host.llm_answers = [ '{"strong_intent": false, "reason": "only asking"}', '{"strong_intent": true, "reason": "asked about invoices"}', + '{"公司名称": "Acme"}', + '{"score": 10, "reason": "browsing"}', ] with bind_invocation(host, config=CONFIG, binding=binding()): await fire(handlers, events.NormalMessageResponded, responded("first answer")) @@ -362,7 +368,7 @@ async def test_page_lists_sessions_and_shows_timeline(monkeypatch): listing = await call_page(page, "/sessions") assert listing.error is None totals, sessions = listing.data["totals"], listing.data["sessions"] - assert totals == {"sessions": 2, "asked": 0, "captured": 1, "emailed": 1, "judgments": 3} + assert totals == {"sessions": 2, "asked": 0, "risk": 0, "captured": 1, "emailed": 1, "judgments": 3} captured = next(item for item in sessions if item["user"] == USER) assert captured["status"] == "captured" and captured["contact"] == "13800138000" assert captured["judgments"] == 2 and captured["strong_judgments"] == 1 @@ -370,7 +376,8 @@ async def test_page_lists_sessions_and_shows_timeline(monkeypatch): detail = await call_page(page, "/session", {"key": captured["key"]}, "POST") assert detail.error is None kinds = [event["kind"] for event in detail.data["session"]["events"]] - assert kinds == ["judge", "judge", "asked", "contact", "email"] + assert kinds == ["judge", "judge", "asked", "contact", "fields", "email"] + assert detail.data["session"]["fields"]["公司名称"] == "Acme" assert detail.data["session"]["events"][0]["message"] == "年付多少钱?" assert [m["text"] for m in detail.data["conversation"]] == ["年付多少钱?", "99 元"] assert len(resend.requests) == 1 @@ -421,3 +428,95 @@ def test_page_i18n_keys_match_and_cover_html(): html = (folder / "index.html").read_text() used = set(re.findall(r'data-i18n="([^"]+)"', html)) | set(re.findall(r"t\('([^']+)'", html)) assert used <= set(en), used - set(en) + + + +SCORING = {**CONFIG, "high_score": 60, "ask_score": 30} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("score,level,asks", [(82, "high", True), (45, "medium", True), (29, "low", False)]) +async def test_score_tiers_decide_the_single_ask(score, level, asks): + _plugin, host, handlers = await graph() + host.llm_answers = [json.dumps({"score": score, "signals": ["询价", "发票"], "risk": False, "reason": "r"})] + first, second = responded("a1"), responded("a2") + with bind_invocation(host, config=SCORING, binding=binding()): + await fire(handlers, events.NormalMessageResponded, first) + await fire(handlers, events.NormalMessageResponded, second) + state = json.loads(next(iter(host.storage.values()))) + judge = state["events"][0] + assert (judge["score"], judge["level"], judge["signals"]) == (score, level, ["询价", "发票"]) + assert state["score"] == score and state["level"] == level + assert (reply_text(first) == "a1\n\nASK-CONTACT") is asks + assert state["status"] == ("asked" if asks else "idle") + assert reply_text(second) is None + assert len(host.llm_calls) == (1 if asks else 2) # asked users are never re-judged + + +@pytest.mark.asyncio +async def test_thresholds_are_configurable(): + _plugin, host, handlers = await graph() + host.llm_answers = ['{"score": 45}'] + ctx = responded() + with bind_invocation(host, config={**SCORING, "ask_score": 60}, binding=binding()): + await fire(handlers, events.NormalMessageResponded, ctx) + assert reply_text(ctx) is None + # Raising the ask threshold to the high-intent line means only high intent is asked; 45 is then low. + assert json.loads(next(iter(host.storage.values())))["level"] == "low" + + +@pytest.mark.asyncio +async def test_compliance_risk_never_asks_or_emails(monkeypatch): + resend = Resend().install(monkeypatch) + _plugin, host, handlers = await graph() + host.llm_answers = ['{"score": 90, "risk": true, "risk_reason": "抢票", "reason": "wants bulk tickets"}'] + first, later = responded("a1"), responded("a2") + with bind_invocation(host, config=SCORING, binding=binding()): + await fire(handlers, events.NormalMessageResponded, first) + await fire(handlers, events.PersonMessageReceived, received("13800138000")) + await fire(handlers, events.NormalMessageResponded, later) + state = json.loads(next(iter(host.storage.values()))) + assert state["status"] == "risk" and state["reason"] == "抢票" + assert [event["kind"] for event in state["events"]] == ["judge", "risk"] + assert reply_text(first) is None and reply_text(later) is None + assert resend.requests == [] and len(host.llm_calls) == 1 + + +@pytest.mark.asyncio +async def test_email_has_score_and_extracted_fields(monkeypatch): + resend = Resend().install(monkeypatch) + _plugin, host, handlers = await graph() + host.history = [row("user", "我们公司 Acme 做跨境电商,每月 500 万次请求,下周上线")] + host.llm_answers = [ + '{"score": 75, "signals": ["大规模请求", "时间紧迫"], "reason": "规模大且着急"}', + json.dumps({"公司名称": "Acme", "行业": "跨境电商", "预估月请求量或数据量": "500 万次", "上线时间": "下周", + "预算范围": "", "made_up": "x"}, ensure_ascii=False), + ] + config = {**SCORING, "lead_fields": ["公司名称", "行业", "预估月请求量或数据量", "预算范围", "上线时间"]} + with bind_invocation(host, config=config, binding=binding()): + await fire(handlers, events.NormalMessageResponded, responded()) + await fire(handlers, events.PersonMessageReceived, received("微信 acme_boss1")) + payload = json.loads(resend.requests[0].content) + assert payload["subject"].startswith("【购买意向·高意向 75分】微信: acme_boss1") + assert "意向评分:75 分(高意向)" in payload["text"] + assert "命中信号:大规模请求、时间紧迫" in payload["text"] + assert "公司名称:Acme" in payload["text"] and "预算范围:未提及" in payload["text"] + assert "made_up" not in payload["text"] + state = json.loads(next(iter(host.storage.values()))) + assert state["fields"] == {"公司名称": "Acme", "行业": "跨境电商", "预估月请求量或数据量": "500 万次", "预算范围": "", "上线时间": "下周"} + + +@pytest.mark.asyncio +async def test_unparseable_judgment_is_recorded_as_error(): + _plugin, host, handlers = await graph() + host.llm_answers = ["I think the user wants to buy"] + ctx = responded() + with bind_invocation(host, config=SCORING, binding=binding()): + await fire(handlers, events.NormalMessageResponded, ctx) + state = json.loads(next(iter(host.storage.values()))) + assert reply_text(ctx) is None and state["events"][0]["kind"] == "error" + + +def test_score_parsing_is_clamped(): + assert lead_main.to_score("87.6") == 88 + assert lead_main.to_score(150) == 100 and lead_main.to_score(-5) == 0 and lead_main.to_score("x") == 0