Fix chat agent web search, greeting resume, and lead capture gaps
Browse filesCompetitor comparison tables defaulted straight to "Requires confirmation"
because the agentic prompt never told the model to actually use its
search_the_web tool for the comparison — only that the tool existed.
Now the model is instructed to search before falling back, and to label
web-sourced cells as unverified public info rather than a settled claim.
The widget's opening greeting silently disappeared on any repeat open
within the same browser (existing localStorage session skipped the
greeting fetch entirely, with no history shown either). Added a session-
resume endpoint so reopening replays prior messages, or shows the
greeting if nothing was exchanged yet.
Chat-agent-sourced leads were linked into Contacts the same way as
Smartlead reply-imports, which the enrichment "Fetch" gate explicitly
excludes — and no extraction logic existed at all for company name, only
visitor name. Chat-agent leads now get form-style (enrichable) contacts,
and company name is extracted from the conversation the same way name
already was.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- backend/app/chat_agent_chat.py +57 -8
- backend/app/chat_agent_routes.py +108 -14
- backend/app/crm_lead_contacts.py +34 -15
- backend/app/database.py +7 -0
- backend/app/main.py +1 -1
|
@@ -163,6 +163,40 @@ def extract_visitor_name(recent_turns: List[Dict[str, str]]) -> str:
|
|
| 163 |
return ""
|
| 164 |
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
def build_system_prompt(
|
| 167 |
*,
|
| 168 |
skills_md: str,
|
|
@@ -285,14 +319,27 @@ def build_agentic_system_prompt(
|
|
| 285 |
"search for the competitor's name or the general topic) before concluding you don't have "
|
| 286 |
"enough — a single narrow miss doesn't mean the knowledge base has nothing relevant.",
|
| 287 |
"- When you do reply to a comparison request, default to a table (columns like "
|
| 288 |
-
"'Evaluation area | Our approach | Alternative approach | What to validate')
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
"pricing rule below — if a row would compare pricing, cost, or discounts, write 'Not "
|
| 294 |
"disclosed via chat' in the Our approach cell rather than any figure, even one that came "
|
| 295 |
-
"back from
|
| 296 |
"next validation step (e.g. a structured demo using the same use case).",
|
| 297 |
"- If the knowledge base still doesn't cover something after searching, and it's not a "
|
| 298 |
"comparison request (see the rule above for those), your ENTIRE answer is: say so "
|
|
@@ -319,12 +366,14 @@ def build_agentic_system_prompt(
|
|
| 319 |
"- You also have a search_the_web tool for live PUBLIC web search, to use only when "
|
| 320 |
"search_knowledge_base has already been tried and didn't have enough for a detailed, "
|
| 321 |
"useful answer — e.g. background on a named competitor, industry/technology context, "
|
| 322 |
-
"or an integration/partner ecosystem question.
|
|
|
|
|
|
|
| 323 |
"our own company, our own pricing, our own confidential/internal information, or "
|
| 324 |
"personal information about a private individual. Its results are external and "
|
| 325 |
"unverified — they're still subject to every rule above (never disclose confidential "
|
| 326 |
"info, never insult or speculate about competitors, never state pricing for anyone), "
|
| 327 |
-
"so present them as
|
| 328 |
)
|
| 329 |
|
| 330 |
if skills_md.strip():
|
|
|
|
| 163 |
return ""
|
| 164 |
|
| 165 |
|
| 166 |
+
def extract_visitor_company(recent_turns: List[Dict[str, str]]) -> str:
|
| 167 |
+
"""
|
| 168 |
+
Same pattern as extract_visitor_name — small, cheap, non-streaming call, only invoked once we
|
| 169 |
+
have an email but no company yet, to pull the visitor's company name out of the last few chat
|
| 170 |
+
turns (e.g. the model's own lead-capture follow-up asking for it, per build_system_prompt's
|
| 171 |
+
LEAD CAPTURE instructions). Returns "" if no company was actually stated (never invents one).
|
| 172 |
+
"""
|
| 173 |
+
convo = "\n".join(f"{t['role']}: {t['content']}" for t in recent_turns[-6:])
|
| 174 |
+
try:
|
| 175 |
+
resp = _openai_client().chat.completions.create(
|
| 176 |
+
model=CHAT_MODEL_OPENAI,
|
| 177 |
+
messages=[
|
| 178 |
+
{
|
| 179 |
+
"role": "system",
|
| 180 |
+
"content": (
|
| 181 |
+
"Extract ONLY the human visitor's company/organization name if they "
|
| 182 |
+
"explicitly stated it in this chat transcript. Reply with just the company "
|
| 183 |
+
"name and nothing else. If no company was stated, reply with exactly: none"
|
| 184 |
+
),
|
| 185 |
+
},
|
| 186 |
+
{"role": "user", "content": convo},
|
| 187 |
+
],
|
| 188 |
+
temperature=0,
|
| 189 |
+
max_tokens=20,
|
| 190 |
+
)
|
| 191 |
+
text = (resp.choices[0].message.content or "").strip()
|
| 192 |
+
if not text or text.lower() == "none":
|
| 193 |
+
return ""
|
| 194 |
+
return text[:200]
|
| 195 |
+
except Exception as e:
|
| 196 |
+
logger.warning("Visitor company extraction failed: %s", e)
|
| 197 |
+
return ""
|
| 198 |
+
|
| 199 |
+
|
| 200 |
def build_system_prompt(
|
| 201 |
*,
|
| 202 |
skills_md: str,
|
|
|
|
| 319 |
"search for the competitor's name or the general topic) before concluding you don't have "
|
| 320 |
"enough — a single narrow miss doesn't mean the knowledge base has nothing relevant.",
|
| 321 |
"- When you do reply to a comparison request, default to a table (columns like "
|
| 322 |
+
"'Evaluation area | Our approach | Alternative approach | What to validate')."
|
| 323 |
+
+ (
|
| 324 |
+
" Before writing anything in the Alternative approach column, call search_the_web "
|
| 325 |
+
"(once is usually enough) for the named competitor/alternative — do not default "
|
| 326 |
+
"straight to 'Requires confirmation' without having tried it. When a cell is based on "
|
| 327 |
+
"a search_the_web result, phrase it as a short neutral fact prefixed with 'Per public "
|
| 328 |
+
"web sources (unverified):' so it reads as external, unverified information rather "
|
| 329 |
+
"than a settled claim about a company you don't have inside knowledge of. Only write "
|
| 330 |
+
"'Requires confirmation' for a specific cell if search_the_web also came back empty, "
|
| 331 |
+
"vague, or unclear on that particular point."
|
| 332 |
+
if web_search_enabled
|
| 333 |
+
else " Mark cells you can't confirm as 'Requires confirmation' rather than skipping "
|
| 334 |
+
"the comparison entirely or giving only a vague capability summary."
|
| 335 |
+
)
|
| 336 |
+
+ " Never state a specific claim about the other product unless it came from an actual "
|
| 337 |
+
"search_knowledge_base"
|
| 338 |
+
+ (" or search_the_web" if web_search_enabled else "")
|
| 339 |
+
+ " result. This table is not an exception to the "
|
| 340 |
"pricing rule below — if a row would compare pricing, cost, or discounts, write 'Not "
|
| 341 |
"disclosed via chat' in the Our approach cell rather than any figure, even one that came "
|
| 342 |
+
"back from a tool. End with a recommendation for the single most useful "
|
| 343 |
"next validation step (e.g. a structured demo using the same use case).",
|
| 344 |
"- If the knowledge base still doesn't cover something after searching, and it's not a "
|
| 345 |
"comparison request (see the rule above for those), your ENTIRE answer is: say so "
|
|
|
|
| 366 |
"- You also have a search_the_web tool for live PUBLIC web search, to use only when "
|
| 367 |
"search_knowledge_base has already been tried and didn't have enough for a detailed, "
|
| 368 |
"useful answer — e.g. background on a named competitor, industry/technology context, "
|
| 369 |
+
"or an integration/partner ecosystem question. This is the primary tool for filling in "
|
| 370 |
+
"a competitor comparison table (see the comparison-request rule above) — don't skip it "
|
| 371 |
+
"and fall back to a placeholder without trying. Never use it to look up anything about "
|
| 372 |
"our own company, our own pricing, our own confidential/internal information, or "
|
| 373 |
"personal information about a private individual. Its results are external and "
|
| 374 |
"unverified — they're still subject to every rule above (never disclose confidential "
|
| 375 |
"info, never insult or speculate about competitors, never state pricing for anyone), "
|
| 376 |
+
"so always present them as unverified public information, not settled fact."
|
| 377 |
)
|
| 378 |
|
| 379 |
if skills_md.strip():
|
|
@@ -26,6 +26,7 @@ from .chat_agent_chat import (
|
|
| 26 |
build_agentic_system_prompt,
|
| 27 |
build_system_prompt,
|
| 28 |
extract_email,
|
|
|
|
| 29 |
extract_visitor_name,
|
| 30 |
run_eval_assertion,
|
| 31 |
stream_agentic_reply_openai,
|
|
@@ -57,6 +58,7 @@ from .database import (
|
|
| 57 |
ChatAgentIndexLog,
|
| 58 |
ChatAgentMessage,
|
| 59 |
ChatAgentSkill,
|
|
|
|
| 60 |
CrmLead,
|
| 61 |
db_commit_with_retry,
|
| 62 |
get_db,
|
|
@@ -673,6 +675,7 @@ def _capture_or_update_lead(db: Session, cfg: ChatAgentConfig, convo: ChatAgentC
|
|
| 673 |
email=email,
|
| 674 |
first_name=first_name,
|
| 675 |
last_name=last_name,
|
|
|
|
| 676 |
last_reply_subject="Website chat",
|
| 677 |
last_reply_body=last_body,
|
| 678 |
last_reply_at=datetime.utcnow(),
|
|
@@ -693,6 +696,8 @@ def _capture_or_update_lead(db: Session, cfg: ChatAgentConfig, convo: ChatAgentC
|
|
| 693 |
lead.first_name = first_name
|
| 694 |
if last_name and not lead.last_name:
|
| 695 |
lead.last_name = last_name
|
|
|
|
|
|
|
| 696 |
lead.last_reply_body = last_body
|
| 697 |
lead.last_reply_at = datetime.utcnow()
|
| 698 |
rw = dict(lead.raw_webhook) if isinstance(lead.raw_webhook, dict) else {}
|
|
@@ -705,6 +710,16 @@ def _capture_or_update_lead(db: Session, cfg: ChatAgentConfig, convo: ChatAgentC
|
|
| 705 |
lead.updated_at = datetime.utcnow()
|
| 706 |
if not lead.contact_id:
|
| 707 |
link_lead_to_contact(db, lead)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 708 |
|
| 709 |
convo.lead_id = lead.id
|
| 710 |
db_commit_with_retry(db)
|
|
@@ -746,6 +761,48 @@ def public_create_session(public_id: str, body: ChatAgentSessionRequest, db: Ses
|
|
| 746 |
)
|
| 747 |
|
| 748 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 749 |
@router.options("/api/public/chat-agent/{public_id}/message")
|
| 750 |
async def public_message_options():
|
| 751 |
return Response(headers=_CORS_HEADERS)
|
|
@@ -770,11 +827,22 @@ async def public_send_message(public_id: str, body: ChatAgentMessageRequest, db:
|
|
| 770 |
found_email = extract_email(user_text)
|
| 771 |
if found_email and not convo.visitor_email:
|
| 772 |
convo.visitor_email = found_email
|
| 773 |
-
if convo.visitor_email and not convo.visitor_name:
|
| 774 |
recent = _recent_messages_for_prompt(db, convo.id)
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 778 |
db_commit_with_retry(db)
|
| 779 |
|
| 780 |
tenant_id = cfg.tenant_id
|
|
@@ -1059,22 +1127,48 @@ _WIDGET_JS_TEMPLATE = r"""(function () {
|
|
| 1059 |
flushText();
|
| 1060 |
}
|
| 1061 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1062 |
function ensureSession() {
|
| 1063 |
if (sessionReady) return sessionReady;
|
| 1064 |
sessionReady = (function () {
|
| 1065 |
try {
|
| 1066 |
sessionId = window.localStorage.getItem(STORAGE_KEY);
|
| 1067 |
} catch (e) {}
|
| 1068 |
-
if (sessionId) return
|
| 1069 |
-
|
| 1070 |
-
|
| 1071 |
-
|
| 1072 |
-
|
| 1073 |
-
|
| 1074 |
-
|
| 1075 |
-
|
| 1076 |
-
|
| 1077 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1078 |
})();
|
| 1079 |
return sessionReady;
|
| 1080 |
}
|
|
|
|
| 26 |
build_agentic_system_prompt,
|
| 27 |
build_system_prompt,
|
| 28 |
extract_email,
|
| 29 |
+
extract_visitor_company,
|
| 30 |
extract_visitor_name,
|
| 31 |
run_eval_assertion,
|
| 32 |
stream_agentic_reply_openai,
|
|
|
|
| 58 |
ChatAgentIndexLog,
|
| 59 |
ChatAgentMessage,
|
| 60 |
ChatAgentSkill,
|
| 61 |
+
Contact,
|
| 62 |
CrmLead,
|
| 63 |
db_commit_with_retry,
|
| 64 |
get_db,
|
|
|
|
| 675 |
email=email,
|
| 676 |
first_name=first_name,
|
| 677 |
last_name=last_name,
|
| 678 |
+
company_name=convo.visitor_company or "",
|
| 679 |
last_reply_subject="Website chat",
|
| 680 |
last_reply_body=last_body,
|
| 681 |
last_reply_at=datetime.utcnow(),
|
|
|
|
| 696 |
lead.first_name = first_name
|
| 697 |
if last_name and not lead.last_name:
|
| 698 |
lead.last_name = last_name
|
| 699 |
+
if convo.visitor_company and not lead.company_name:
|
| 700 |
+
lead.company_name = convo.visitor_company
|
| 701 |
lead.last_reply_body = last_body
|
| 702 |
lead.last_reply_at = datetime.utcnow()
|
| 703 |
rw = dict(lead.raw_webhook) if isinstance(lead.raw_webhook, dict) else {}
|
|
|
|
| 710 |
lead.updated_at = datetime.utcnow()
|
| 711 |
if not lead.contact_id:
|
| 712 |
link_lead_to_contact(db, lead)
|
| 713 |
+
elif convo.visitor_company:
|
| 714 |
+
# Contact may already exist (e.g. matched by email to a prior record) — keep its
|
| 715 |
+
# company in sync now that we know it, same as _merge_lead_fields_into_contact does
|
| 716 |
+
# for a brand-new link.
|
| 717 |
+
contact = db.query(Contact).filter(Contact.id == lead.contact_id).first()
|
| 718 |
+
if contact and not contact.company:
|
| 719 |
+
contact.company = convo.visitor_company
|
| 720 |
+
rd = dict(contact.raw_data or {})
|
| 721 |
+
rd["Company Name"] = convo.visitor_company
|
| 722 |
+
contact.raw_data = rd
|
| 723 |
|
| 724 |
convo.lead_id = lead.id
|
| 725 |
db_commit_with_retry(db)
|
|
|
|
| 761 |
)
|
| 762 |
|
| 763 |
|
| 764 |
+
@router.options("/api/public/chat-agent/{public_id}/session/{session_id}")
|
| 765 |
+
async def public_session_lookup_options():
|
| 766 |
+
return Response(headers=_CORS_HEADERS)
|
| 767 |
+
|
| 768 |
+
|
| 769 |
+
@router.get("/api/public/chat-agent/{public_id}/session/{session_id}")
|
| 770 |
+
def public_get_session(public_id: str, session_id: str, db: Session = Depends(get_db)):
|
| 771 |
+
"""Resume an existing widget session (e.g. reopening the widget after a page reload, where
|
| 772 |
+
the client remembers session_id in localStorage). Returns prior messages so the widget can
|
| 773 |
+
replay the conversation instead of showing a blank panel; the greeting is included too, for
|
| 774 |
+
the edge case where a session was created but the panel was closed before anything was ever
|
| 775 |
+
exchanged (no messages yet, so nothing to replay)."""
|
| 776 |
+
try:
|
| 777 |
+
cfg = _get_enabled_config(db, public_id)
|
| 778 |
+
except HTTPException as e:
|
| 779 |
+
# Cross-origin callers need CORS headers on error responses too, or the browser reports
|
| 780 |
+
# a generic network failure instead of a readable 404 — the widget's fetch call handles
|
| 781 |
+
# either the same way (falls back to creating a fresh session), but a real status lets
|
| 782 |
+
# that fallback be reliable rather than accidental.
|
| 783 |
+
return JSONResponse({"detail": e.detail}, status_code=e.status_code, headers=_CORS_HEADERS)
|
| 784 |
+
convo = (
|
| 785 |
+
db.query(ChatAgentConversation)
|
| 786 |
+
.filter(ChatAgentConversation.tenant_id == cfg.tenant_id, ChatAgentConversation.session_id == session_id)
|
| 787 |
+
.first()
|
| 788 |
+
)
|
| 789 |
+
if not convo:
|
| 790 |
+
return JSONResponse({"detail": "Session not found"}, status_code=404, headers=_CORS_HEADERS)
|
| 791 |
+
rows = (
|
| 792 |
+
db.query(ChatAgentMessage)
|
| 793 |
+
.filter(ChatAgentMessage.conversation_id == convo.id)
|
| 794 |
+
.order_by(ChatAgentMessage.created_at.asc(), ChatAgentMessage.id.asc())
|
| 795 |
+
.all()
|
| 796 |
+
)
|
| 797 |
+
return JSONResponse(
|
| 798 |
+
{
|
| 799 |
+
"greeting": cfg.greeting_message or "",
|
| 800 |
+
"messages": [{"role": m.role, "content": m.content or ""} for m in rows],
|
| 801 |
+
},
|
| 802 |
+
headers=_CORS_HEADERS,
|
| 803 |
+
)
|
| 804 |
+
|
| 805 |
+
|
| 806 |
@router.options("/api/public/chat-agent/{public_id}/message")
|
| 807 |
async def public_message_options():
|
| 808 |
return Response(headers=_CORS_HEADERS)
|
|
|
|
| 827 |
found_email = extract_email(user_text)
|
| 828 |
if found_email and not convo.visitor_email:
|
| 829 |
convo.visitor_email = found_email
|
| 830 |
+
if convo.visitor_email and (not convo.visitor_name or not convo.visitor_company):
|
| 831 |
recent = _recent_messages_for_prompt(db, convo.id)
|
| 832 |
+
if not convo.visitor_name:
|
| 833 |
+
name = extract_visitor_name(recent)
|
| 834 |
+
if name:
|
| 835 |
+
convo.visitor_name = name
|
| 836 |
+
if not convo.visitor_company:
|
| 837 |
+
company = extract_visitor_company(recent)
|
| 838 |
+
if company:
|
| 839 |
+
convo.visitor_company = company
|
| 840 |
+
# A lead may already exist for this conversation (company arriving a turn or two
|
| 841 |
+
# after name/email did) — keep it in sync rather than only setting it at creation.
|
| 842 |
+
if convo.lead_id:
|
| 843 |
+
existing_lead = db.query(CrmLead).filter(CrmLead.id == convo.lead_id).first()
|
| 844 |
+
if existing_lead and not existing_lead.company_name:
|
| 845 |
+
existing_lead.company_name = company
|
| 846 |
db_commit_with_retry(db)
|
| 847 |
|
| 848 |
tenant_id = cfg.tenant_id
|
|
|
|
| 1127 |
flushText();
|
| 1128 |
}
|
| 1129 |
|
| 1130 |
+
function createFreshSession() {
|
| 1131 |
+
return fetch(API + '/api/public/chat-agent/' + PUBLIC_ID + '/session', {
|
| 1132 |
+
method: 'POST',
|
| 1133 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1134 |
+
body: JSON.stringify({ page_url: window.location.href })
|
| 1135 |
+
}).then(function (r) { return r.json(); }).then(function (d) {
|
| 1136 |
+
sessionId = d.session_id;
|
| 1137 |
+
try { window.localStorage.setItem(STORAGE_KEY, sessionId); } catch (e) {}
|
| 1138 |
+
if (d.greeting) addMsg('assistant', d.greeting);
|
| 1139 |
+
});
|
| 1140 |
+
}
|
| 1141 |
+
|
| 1142 |
function ensureSession() {
|
| 1143 |
if (sessionReady) return sessionReady;
|
| 1144 |
sessionReady = (function () {
|
| 1145 |
try {
|
| 1146 |
sessionId = window.localStorage.getItem(STORAGE_KEY);
|
| 1147 |
} catch (e) {}
|
| 1148 |
+
if (!sessionId) return createFreshSession();
|
| 1149 |
+
// Reopening the widget with a session remembered from an earlier page load — replay
|
| 1150 |
+
// whatever was actually exchanged so far instead of showing a blank panel. If the
|
| 1151 |
+
// session no longer exists server-side (expired/deleted), fall back to a fresh one.
|
| 1152 |
+
return fetch(API + '/api/public/chat-agent/' + PUBLIC_ID + '/session/' + sessionId)
|
| 1153 |
+
.then(function (r) {
|
| 1154 |
+
if (!r.ok) throw new Error('session lookup failed');
|
| 1155 |
+
return r.json();
|
| 1156 |
+
})
|
| 1157 |
+
.then(function (d) {
|
| 1158 |
+
if (d.messages && d.messages.length) {
|
| 1159 |
+
d.messages.forEach(function (m) {
|
| 1160 |
+
var el = addMsg(m.role, '');
|
| 1161 |
+
renderRichContent(el, m.content || '');
|
| 1162 |
+
});
|
| 1163 |
+
} else if (d.greeting) {
|
| 1164 |
+
addMsg('assistant', d.greeting);
|
| 1165 |
+
}
|
| 1166 |
+
})
|
| 1167 |
+
.catch(function () {
|
| 1168 |
+
try { window.localStorage.removeItem(STORAGE_KEY); } catch (e) {}
|
| 1169 |
+
sessionId = null;
|
| 1170 |
+
return createFreshSession();
|
| 1171 |
+
});
|
| 1172 |
})();
|
| 1173 |
return sessionReady;
|
| 1174 |
}
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Link CRM leads to Contacts rows (campaign replies, CTA forms, manual move)."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
@@ -12,13 +12,27 @@ from .database import Contact, CrmLead
|
|
| 12 |
SMARTLEAD_IMPORT_FILE_ID = "smartlead-imports"
|
| 13 |
MANUAL_CONTACT_FILE_ID = "manual-contacts"
|
| 14 |
CTA_FORM_SOURCE = "cta_form"
|
|
|
|
|
|
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
|
|
|
|
| 18 |
rw = lead.raw_webhook if isinstance(lead.raw_webhook, dict) else {}
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
def _merge_lead_fields_into_contact(contact: Contact, lead: CrmLead) -> None:
|
|
@@ -43,8 +57,8 @@ def _merge_lead_fields_into_contact(contact: Contact, lead: CrmLead) -> None:
|
|
| 43 |
contact.raw_data = rd
|
| 44 |
|
| 45 |
|
| 46 |
-
def _contact_raw_for_lead(lead: CrmLead, *,
|
| 47 |
-
if
|
| 48 |
rw = lead.raw_webhook if isinstance(lead.raw_webhook, dict) else {}
|
| 49 |
return {
|
| 50 |
"First Name": lead.first_name or "",
|
|
@@ -52,13 +66,16 @@ def _contact_raw_for_lead(lead: CrmLead, *, is_cta: bool) -> dict:
|
|
| 52 |
"Email": (lead.email or "").strip(),
|
| 53 |
"Company Name": lead.company_name or "",
|
| 54 |
"Title": lead.title or "",
|
| 55 |
-
"source":
|
| 56 |
"form_id": rw.get("form_id"),
|
| 57 |
"form_public_id": rw.get("form_public_id"),
|
| 58 |
"form_name": rw.get("form_name"),
|
|
|
|
|
|
|
|
|
|
| 59 |
"last_reply_subject": lead.last_reply_subject or "",
|
| 60 |
"last_reply_body": lead.last_reply_body or "",
|
| 61 |
-
"cta_form_submission": rw,
|
| 62 |
}
|
| 63 |
return {
|
| 64 |
"Company Name": lead.company_name or "",
|
|
@@ -73,7 +90,8 @@ def _contact_raw_for_lead(lead: CrmLead, *, is_cta: bool) -> dict:
|
|
| 73 |
|
| 74 |
|
| 75 |
def link_lead_to_contact(db: Session, lead: CrmLead) -> dict:
|
| 76 |
-
"""Create or link a Contact for this lead.
|
|
|
|
| 77 |
if lead.contact_id:
|
| 78 |
return {"ok": True, "contact_id": lead.contact_id, "message": "Already linked to Contacts"}
|
| 79 |
|
|
@@ -81,7 +99,8 @@ def link_lead_to_contact(db: Session, lead: CrmLead) -> dict:
|
|
| 81 |
if not email:
|
| 82 |
return {"ok": False, "error": "Lead has no email"}
|
| 83 |
|
| 84 |
-
|
|
|
|
| 85 |
existing = (
|
| 86 |
db.query(Contact)
|
| 87 |
.filter(
|
|
@@ -92,21 +111,21 @@ def link_lead_to_contact(db: Session, lead: CrmLead) -> dict:
|
|
| 92 |
)
|
| 93 |
if existing:
|
| 94 |
lead.contact_id = existing.id
|
| 95 |
-
if
|
| 96 |
_merge_lead_fields_into_contact(existing, lead)
|
| 97 |
return {"ok": True, "contact_id": existing.id, "message": "Linked to existing contact"}
|
| 98 |
|
| 99 |
contact = Contact(
|
| 100 |
tenant_id=lead.tenant_id,
|
| 101 |
-
file_id=MANUAL_CONTACT_FILE_ID if
|
| 102 |
row_index=lead.id,
|
| 103 |
first_name=lead.first_name or "",
|
| 104 |
last_name=lead.last_name or "",
|
| 105 |
email=email,
|
| 106 |
company=lead.company_name or "",
|
| 107 |
title=lead.title or "",
|
| 108 |
-
source=
|
| 109 |
-
raw_data=_contact_raw_for_lead(lead,
|
| 110 |
)
|
| 111 |
db.add(contact)
|
| 112 |
db.flush()
|
|
|
|
| 1 |
+
"""Link CRM leads to Contacts rows (campaign replies, CTA forms, chat agent, manual move)."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 12 |
SMARTLEAD_IMPORT_FILE_ID = "smartlead-imports"
|
| 13 |
MANUAL_CONTACT_FILE_ID = "manual-contacts"
|
| 14 |
CTA_FORM_SOURCE = "cta_form"
|
| 15 |
+
CHAT_AGENT_SOURCE = "chat_agent"
|
| 16 |
+
SMARTLEAD_REPLY_SOURCE = "smartlead_reply"
|
| 17 |
|
| 18 |
+
# Leads a human directly typed into a form-like interface (CTA form, chat widget) rather than an
|
| 19 |
+
# inbound reply to an outbound campaign — these get enrichable, manually-added-style Contact
|
| 20 |
+
# records instead of lower-trust reply-import ones.
|
| 21 |
+
_FORM_STYLE_SOURCES = {CTA_FORM_SOURCE, CHAT_AGENT_SOURCE}
|
| 22 |
|
| 23 |
+
|
| 24 |
+
def _lead_origin_source(lead: CrmLead) -> str:
|
| 25 |
rw = lead.raw_webhook if isinstance(lead.raw_webhook, dict) else {}
|
| 26 |
+
source = rw.get("source")
|
| 27 |
+
if source in _FORM_STYLE_SOURCES:
|
| 28 |
+
return source
|
| 29 |
+
if (lead.smartlead_lead_id or "").startswith("cta-"):
|
| 30 |
+
return CTA_FORM_SOURCE
|
| 31 |
+
return SMARTLEAD_REPLY_SOURCE
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def is_form_style_lead(lead: CrmLead) -> bool:
|
| 35 |
+
return _lead_origin_source(lead) in _FORM_STYLE_SOURCES
|
| 36 |
|
| 37 |
|
| 38 |
def _merge_lead_fields_into_contact(contact: Contact, lead: CrmLead) -> None:
|
|
|
|
| 57 |
contact.raw_data = rd
|
| 58 |
|
| 59 |
|
| 60 |
+
def _contact_raw_for_lead(lead: CrmLead, *, origin_source: str) -> dict:
|
| 61 |
+
if origin_source in _FORM_STYLE_SOURCES:
|
| 62 |
rw = lead.raw_webhook if isinstance(lead.raw_webhook, dict) else {}
|
| 63 |
return {
|
| 64 |
"First Name": lead.first_name or "",
|
|
|
|
| 66 |
"Email": (lead.email or "").strip(),
|
| 67 |
"Company Name": lead.company_name or "",
|
| 68 |
"Title": lead.title or "",
|
| 69 |
+
"source": origin_source,
|
| 70 |
"form_id": rw.get("form_id"),
|
| 71 |
"form_public_id": rw.get("form_public_id"),
|
| 72 |
"form_name": rw.get("form_name"),
|
| 73 |
+
"conversation_id": rw.get("conversation_id"),
|
| 74 |
+
"session_id": rw.get("session_id"),
|
| 75 |
+
"page_url": rw.get("page_url"),
|
| 76 |
"last_reply_subject": lead.last_reply_subject or "",
|
| 77 |
"last_reply_body": lead.last_reply_body or "",
|
| 78 |
+
"cta_form_submission": rw if origin_source == CTA_FORM_SOURCE else None,
|
| 79 |
}
|
| 80 |
return {
|
| 81 |
"Company Name": lead.company_name or "",
|
|
|
|
| 90 |
|
| 91 |
|
| 92 |
def link_lead_to_contact(db: Session, lead: CrmLead) -> dict:
|
| 93 |
+
"""Create or link a Contact for this lead. Form-style leads (CTA form, chat agent) use
|
| 94 |
+
manual-style contacts (enrichable)."""
|
| 95 |
if lead.contact_id:
|
| 96 |
return {"ok": True, "contact_id": lead.contact_id, "message": "Already linked to Contacts"}
|
| 97 |
|
|
|
|
| 99 |
if not email:
|
| 100 |
return {"ok": False, "error": "Lead has no email"}
|
| 101 |
|
| 102 |
+
origin_source = _lead_origin_source(lead)
|
| 103 |
+
is_form_style = origin_source in _FORM_STYLE_SOURCES
|
| 104 |
existing = (
|
| 105 |
db.query(Contact)
|
| 106 |
.filter(
|
|
|
|
| 111 |
)
|
| 112 |
if existing:
|
| 113 |
lead.contact_id = existing.id
|
| 114 |
+
if is_form_style:
|
| 115 |
_merge_lead_fields_into_contact(existing, lead)
|
| 116 |
return {"ok": True, "contact_id": existing.id, "message": "Linked to existing contact"}
|
| 117 |
|
| 118 |
contact = Contact(
|
| 119 |
tenant_id=lead.tenant_id,
|
| 120 |
+
file_id=MANUAL_CONTACT_FILE_ID if is_form_style else SMARTLEAD_IMPORT_FILE_ID,
|
| 121 |
row_index=lead.id,
|
| 122 |
first_name=lead.first_name or "",
|
| 123 |
last_name=lead.last_name or "",
|
| 124 |
email=email,
|
| 125 |
company=lead.company_name or "",
|
| 126 |
title=lead.title or "",
|
| 127 |
+
source=origin_source if is_form_style else "smartlead",
|
| 128 |
+
raw_data=_contact_raw_for_lead(lead, origin_source=origin_source),
|
| 129 |
)
|
| 130 |
db.add(contact)
|
| 131 |
db.flush()
|
|
@@ -609,6 +609,7 @@ class ChatAgentConversation(Base):
|
|
| 609 |
session_id = Column(String, unique=True, index=True, nullable=False)
|
| 610 |
visitor_name = Column(String, default="")
|
| 611 |
visitor_email = Column(String, default="", index=True)
|
|
|
|
| 612 |
lead_id = Column(Integer, ForeignKey("crm_leads.id"), nullable=True, index=True)
|
| 613 |
page_url = Column(String, default="")
|
| 614 |
started_at = Column(DateTime, default=datetime.utcnow)
|
|
@@ -811,6 +812,12 @@ def run_migrations(connection_engine):
|
|
| 811 |
if "generation_cancel_requested" not in ufcols:
|
| 812 |
conn.execute(text("ALTER TABLE uploaded_files ADD COLUMN generation_cancel_requested INTEGER DEFAULT 0"))
|
| 813 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 814 |
insp = inspect(connection_engine)
|
| 815 |
if insp.has_table("unipile_accounts"):
|
| 816 |
uacols = [c["name"] for c in insp.get_columns("unipile_accounts")]
|
|
|
|
| 609 |
session_id = Column(String, unique=True, index=True, nullable=False)
|
| 610 |
visitor_name = Column(String, default="")
|
| 611 |
visitor_email = Column(String, default="", index=True)
|
| 612 |
+
visitor_company = Column(String, default="")
|
| 613 |
lead_id = Column(Integer, ForeignKey("crm_leads.id"), nullable=True, index=True)
|
| 614 |
page_url = Column(String, default="")
|
| 615 |
started_at = Column(DateTime, default=datetime.utcnow)
|
|
|
|
| 812 |
if "generation_cancel_requested" not in ufcols:
|
| 813 |
conn.execute(text("ALTER TABLE uploaded_files ADD COLUMN generation_cancel_requested INTEGER DEFAULT 0"))
|
| 814 |
|
| 815 |
+
insp = inspect(connection_engine)
|
| 816 |
+
if insp.has_table("chat_agent_conversations"):
|
| 817 |
+
caccols = [c["name"] for c in insp.get_columns("chat_agent_conversations")]
|
| 818 |
+
if "visitor_company" not in caccols:
|
| 819 |
+
conn.execute(text("ALTER TABLE chat_agent_conversations ADD COLUMN visitor_company TEXT DEFAULT ''"))
|
| 820 |
+
|
| 821 |
insp = inspect(connection_engine)
|
| 822 |
if insp.has_table("unipile_accounts"):
|
| 823 |
uacols = [c["name"] for c in insp.get_columns("unipile_accounts")]
|
|
@@ -4219,7 +4219,7 @@ async def enrich_contact(contact_id: int, t: TenantContext = Depends(get_tenant_
|
|
| 4219 |
if not contact:
|
| 4220 |
raise HTTPException(status_code=404, detail="Contact not found")
|
| 4221 |
src = (contact.source or "").strip().lower()
|
| 4222 |
-
if src not in ("manual", "cta_form") and contact.file_id != MANUAL_CONTACT_FILE_ID:
|
| 4223 |
raise HTTPException(
|
| 4224 |
status_code=400,
|
| 4225 |
detail="Enrich is only available for manually added or form-submission contacts",
|
|
|
|
| 4219 |
if not contact:
|
| 4220 |
raise HTTPException(status_code=404, detail="Contact not found")
|
| 4221 |
src = (contact.source or "").strip().lower()
|
| 4222 |
+
if src not in ("manual", "cta_form", "chat_agent") and contact.file_id != MANUAL_CONTACT_FILE_ID:
|
| 4223 |
raise HTTPException(
|
| 4224 |
status_code=400,
|
| 4225 |
detail="Enrich is only available for manually added or form-submission contacts",
|