Seth Claude Sonnet 5 commited on
Commit
b5da8dd
·
1 Parent(s): 2d73657

Fix chat agent web search, greeting resume, and lead capture gaps

Browse files

Competitor 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 CHANGED
@@ -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') even when you "
289
- "have no confirmed facts about the other product — mark cells you can't confirm as "
290
- "'Requires confirmation' rather than skipping the comparison entirely or giving only a "
291
- "vague capability summary. Never state a specific claim about the other product unless it "
292
- "came from an actual search_knowledge_base result. This table is not an exception to the "
 
 
 
 
 
 
 
 
 
 
 
 
 
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 search_knowledge_base. End with a recommendation for the single most useful "
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. Never use it to look up anything about "
 
 
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 requiring confirmation, not as settled fact."
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():
backend/app/chat_agent_routes.py CHANGED
@@ -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
- name = extract_visitor_name(recent)
776
- if name:
777
- convo.visitor_name = name
 
 
 
 
 
 
 
 
 
 
 
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 Promise.resolve();
1069
- return fetch(API + '/api/public/chat-agent/' + PUBLIC_ID + '/session', {
1070
- method: 'POST',
1071
- headers: { 'Content-Type': 'application/json' },
1072
- body: JSON.stringify({ page_url: window.location.href })
1073
- }).then(function (r) { return r.json(); }).then(function (d) {
1074
- sessionId = d.session_id;
1075
- try { window.localStorage.setItem(STORAGE_KEY, sessionId); } catch (e) {}
1076
- if (d.greeting) addMsg('assistant', d.greeting);
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
  }
backend/app/crm_lead_contacts.py CHANGED
@@ -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
- def is_cta_form_lead(lead: CrmLead) -> bool:
 
18
  rw = lead.raw_webhook if isinstance(lead.raw_webhook, dict) else {}
19
- if rw.get("source") == CTA_FORM_SOURCE:
20
- return True
21
- return (lead.smartlead_lead_id or "").startswith("cta-")
 
 
 
 
 
 
 
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, *, is_cta: bool) -> dict:
47
- if is_cta:
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": CTA_FORM_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. CTA leads use manual-style contacts (enrichable)."""
 
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
- is_cta = is_cta_form_lead(lead)
 
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 is_cta:
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 is_cta else SMARTLEAD_IMPORT_FILE_ID,
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=CTA_FORM_SOURCE if is_cta else "smartlead",
109
- raw_data=_contact_raw_for_lead(lead, is_cta=is_cta),
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()
backend/app/database.py CHANGED
@@ -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")]
backend/app/main.py CHANGED
@@ -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",