Skip to content

Commit 150b46b

Browse files
committed
feat(leads): implement hot lead checkout flow with telegram integration
Added an extended checkout form for hot leads natively to the chat widget frontend. Updated backend schema to capture 'surname', 'delivery_address' and 'lead_type'. Modified RAG engine and intent handlers to propagate 'lead_form_type' parameter. Integrated Telegram service to send checkout alerts to the hot leads topic. Fixed tests to accommodate updated telegram notification signature.
1 parent bd29ff3 commit 150b46b

8 files changed

Lines changed: 122 additions & 35 deletions

File tree

‎app/api/v1/endpoints/leads.py‎

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,16 @@
2626
wait=wait_exponential(multiplier=1, min=2, max=10),
2727
retry=retry_if_exception_type(httpx.RequestError),
2828
)
29-
async def send_telegram_notification(lead_id: int, message: str) -> None:
29+
async def send_telegram_notification(lead_id: int, message: str, alert_type: str) -> None:
3030
settings = get_settings()
3131
telegram_service = TelegramService(settings)
32-
await telegram_service.send_alert(message, alert_type="lead")
32+
await telegram_service.send_alert(message, alert_type=alert_type)
3333

3434

35-
async def process_lead_background(lead_id: int, message: str) -> None:
35+
async def process_lead_background(lead_id: int, message: str, alert_type: str = "lead") -> None:
3636
async with AsyncSessionLocal() as session:
3737
try:
38-
await send_telegram_notification(lead_id, message)
38+
await send_telegram_notification(lead_id, message, alert_type)
3939
# Оновлюємо статус на sent
4040
lead = await session.get(Lead, lead_id)
4141
if lead:
@@ -91,22 +91,34 @@ async def create_lead(request: Request, background_tasks: BackgroundTasks) -> di
9191
async with AsyncSessionLocal() as session:
9292
db_lead = Lead(
9393
name=lead_data.name,
94+
surname=lead_data.surname,
9495
phone_number=lead_data.phone_number,
9596
contact_method=lead_data.contact_method,
97+
lead_type=lead_data.lead_type,
98+
delivery_address=lead_data.delivery_address,
9699
notification_status="pending",
97100
)
98101
session.add(db_lead)
99102
await commit_with_retry(session)
100103
await session.refresh(db_lead)
101104
lead_id = int(db_lead.id) # type: ignore
102105

103-
message = (
104-
f"🚨 <b>Новий лід з форми зв'язку!</b>\n\n"
105-
f"👤 <b>Ім'я:</b> {lead_data.name}\n"
106-
f"📞 <b>Контакт:</b> <code>{lead_data.phone_number}</code>\n"
107-
f"📱 <b>Спосіб:</b> {lead_data.contact_method}"
108-
)
109-
110-
background_tasks.add_task(process_lead_background, lead_id, message)
106+
if lead_data.lead_type == "checkout":
107+
message = (
108+
f"🛒 <b>Нове ЗАМОВЛЕННЯ (Hot Lead)!</b>\n\n"
109+
f"👤 <b>Ім'я:</b> {lead_data.name} {lead_data.surname or ''}\n"
110+
f"📞 <b>Контакт:</b> <code>{lead_data.phone_number}</code>\n"
111+
f"📱 <b>Спосіб:</b> {lead_data.contact_method}\n"
112+
f"📍 <b>Адреса доставки:</b> {lead_data.delivery_address or 'Не вказана'}"
113+
)
114+
background_tasks.add_task(process_lead_background, lead_id, message, "hot_lead")
115+
else:
116+
message = (
117+
f"🚨 <b>Новий лід з форми зв'язку!</b>\n\n"
118+
f"👤 <b>Ім'я:</b> {lead_data.name}\n"
119+
f"📞 <b>Контакт:</b> <code>{lead_data.phone_number}</code>\n"
120+
f"📱 <b>Спосіб:</b> {lead_data.contact_method}"
121+
)
122+
background_tasks.add_task(process_lead_background, lead_id, message, "lead")
111123

112124
return {"status": "success", "message": "Lead received"}

‎app/models/lead.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ class Lead(Base):
1010

1111
id = Column(Integer, primary_key=True, index=True)
1212
name = Column(String(50), nullable=False)
13+
surname = Column(String(50), nullable=True)
1314
phone_number = Column(String(50), nullable=False)
1415
contact_method = Column(String(20), nullable=False)
16+
lead_type = Column(String(20), default="contact") # contact, checkout
17+
delivery_address = Column(String(255), nullable=True)
1518
notification_status = Column(String(20), default="pending") # pending, sent, failed
1619
created_at = Column(DateTime, default=lambda: datetime.datetime.now(datetime.UTC))

‎app/schemas/chat.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import re
22
from dataclasses import dataclass
3+
from typing import Literal
34

45
from pydantic import BaseModel, Field, field_validator
56

@@ -37,6 +38,9 @@ class RAGResponse(BaseModel):
3738
requires_lead: bool = Field(
3839
default=False, description="Прапорець для активації форми збору контактів на фронтенді"
3940
)
41+
lead_form_type: Literal["contact", "checkout"] | None = Field(
42+
default=None, description="Тип форми для відображення на фронтенді"
43+
)
4044

4145

4246
class ChatResponse(RAGResponse):
@@ -56,6 +60,7 @@ class PipelineContext:
5660
sources: list[str]
5761
extracted_links: list[dict[str, str]]
5862
requires_lead: bool
63+
lead_form_type: Literal["contact", "checkout"] | None
5964
extended_user_message: str
6065

6166

@@ -65,4 +70,5 @@ class IntentContextResult:
6570
system_instructions: list[str]
6671
extracted_links: list[dict[str, str]]
6772
requires_lead: bool
73+
lead_form_type: Literal["contact", "checkout"] | None = None
6874
new_intent_type: str | None = None

‎app/schemas/lead.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,13 @@
66

77
class ContactFormLead(BaseModel):
88
name: str = Field(..., max_length=50, pattern=r"^[A-Za-zА-Яа-яЄєІіЇїҐґ\s\-]+$")
9+
surname: str | None = Field(
10+
default=None, max_length=50, pattern=r"^[A-Za-zА-Яа-яЄєІіЇїҐґ\s\-]+$"
11+
)
912
phone_number: str = Field(..., max_length=50, description="Телефон")
1013
contact_method: Literal["telegram", "viber", "phone"]
14+
lead_type: Literal["contact", "checkout"] = Field(default="contact")
15+
delivery_address: str | None = Field(default=None, max_length=255)
1116
honeypot: str | None = Field(default=None, max_length=50)
1217

1318
@field_validator("phone_number", mode="before")

‎app/services/intent_handlers.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ async def handle(
5656
) -> IntentContextResult:
5757
is_checkout = intent_type == INTENT_CHECKOUT
5858
requires_lead = False
59+
lead_form_type = None
5960
new_intent_type = intent_type
6061

6162
logger.info(
@@ -74,6 +75,7 @@ async def handle(
7475
system_instructions=system_instructions,
7576
extracted_links=extracted_links,
7677
requires_lead=requires_lead,
78+
lead_form_type=lead_form_type,
7779
new_intent_type=INTENT_SEARCH,
7880
)
7981

@@ -111,6 +113,7 @@ async def handle(
111113

112114
elif is_checkout:
113115
requires_lead = True
116+
lead_form_type = "checkout"
114117
if result.woo_url:
115118
extracted_links.append({"text": LINK_CHECKOUT, "url": result.woo_url})
116119
extracted_links.append(
@@ -156,6 +159,7 @@ async def handle(
156159
system_instructions=system_instructions,
157160
extracted_links=extracted_links,
158161
requires_lead=requires_lead,
162+
lead_form_type=lead_form_type,
159163
new_intent_type=new_intent_type,
160164
)
161165

@@ -173,6 +177,7 @@ async def handle(
173177
extracted_links: list[dict[str, str]],
174178
) -> IntentContextResult:
175179
requires_lead = False
180+
lead_form_type = None
176181
strict_term = str(
177182
intent_data.get("strict_query")
178183
or intent_data.get("search_term")
@@ -269,4 +274,5 @@ async def handle(
269274
system_instructions=system_instructions,
270275
extracted_links=extracted_links,
271276
requires_lead=requires_lead,
277+
lead_form_type=lead_form_type,
272278
)

‎app/services/rag_engine.py‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ async def _get_intent_context(
152152
product_facts: list[str] = []
153153
extracted_links: list[dict[str, str]] = []
154154
requires_lead: bool = False
155+
lead_form_type = None
155156

156157
intent_type = intent_data.get("intent", INTENT_FAQ)
157158
product_name = intent_data.get("product_name")
@@ -191,6 +192,7 @@ async def _get_intent_context(
191192
system_instructions = res.system_instructions
192193
extracted_links = res.extracted_links
193194
requires_lead = res.requires_lead
195+
lead_form_type = res.lead_form_type
194196
intent_type = res.new_intent_type or intent_type
195197

196198
if intent_type == INTENT_SEARCH:
@@ -209,12 +211,14 @@ async def _get_intent_context(
209211
system_instructions = res.system_instructions
210212
extracted_links = res.extracted_links
211213
requires_lead = res.requires_lead
214+
lead_form_type = res.lead_form_type
212215

213216
return IntentContextResult(
214217
product_facts=product_facts,
215218
system_instructions=system_instructions,
216219
extracted_links=extracted_links,
217220
requires_lead=requires_lead,
221+
lead_form_type=lead_form_type,
218222
)
219223

220224
async def _try_capture_lead(
@@ -244,6 +248,7 @@ async def _try_capture_lead(
244248
sources=[],
245249
extracted_links=links,
246250
requires_lead=False,
251+
lead_form_type=None,
247252
extended_user_message="",
248253
)
249254
except Exception:
@@ -255,6 +260,7 @@ async def _try_capture_lead(
255260
sources=[],
256261
extracted_links=[],
257262
requires_lead=False,
263+
lead_form_type=None,
258264
extended_user_message="",
259265
)
260266
return False, None
@@ -265,7 +271,7 @@ async def _prepare_rag_pipeline(
265271
"""
266272
Executes the shared pipeline for both sync and stream methods.
267273
Returns:
268-
is_valid, fallback_response, final_context, sources, links, requires_lead, extended_message
274+
is_valid, fallback_response, final_context, sources, links, requires_lead, lead_form_type, extended_message
269275
"""
270276
if not self.guardrails_service.validate_input(question, client_ip=client_ip):
271277
return PipelineContext(
@@ -275,6 +281,7 @@ async def _prepare_rag_pipeline(
275281
sources=[],
276282
extracted_links=[],
277283
requires_lead=False,
284+
lead_form_type=None,
278285
extended_user_message="",
279286
)
280287

@@ -342,11 +349,13 @@ async def fetch_vectors() -> list[
342349
if isinstance(intent_results, BaseException):
343350
logger.error(f"Intent context retrieval failed: {intent_results}")
344351
product_facts, system_instructions, extracted_links, requires_lead = [], [], [], False
352+
lead_form_type = None
345353
else:
346354
product_facts = intent_results.product_facts
347355
system_instructions = intent_results.system_instructions
348356
extracted_links = intent_results.extracted_links
349357
requires_lead = intent_results.requires_lead
358+
lead_form_type = intent_results.lead_form_type
350359

351360
prepended_context: list[str] = []
352361
if system_instructions:
@@ -365,6 +374,7 @@ async def fetch_vectors() -> list[
365374
sources=list(sources),
366375
extracted_links=extracted_links,
367376
requires_lead=requires_lead,
377+
lead_form_type=lead_form_type,
368378
extended_user_message=extended_user_message,
369379
)
370380

@@ -384,6 +394,7 @@ async def process_query(
384394
has_context=False,
385395
links=[],
386396
requires_lead=False,
397+
lead_form_type=None,
387398
)
388399
else:
389400
return RAGResponse(
@@ -392,6 +403,7 @@ async def process_query(
392403
has_context=False,
393404
links=[LinkItem(**link) for link in ctx.extracted_links],
394405
requires_lead=False,
406+
lead_form_type=None,
395407
)
396408

397409
if not ctx.final_context:
@@ -402,6 +414,7 @@ async def process_query(
402414
has_context=False,
403415
links=[],
404416
requires_lead=True,
417+
lead_form_type=None,
405418
)
406419

407420
try:
@@ -424,6 +437,7 @@ async def process_query(
424437
"has_context": False,
425438
"links": [],
426439
"requires_lead": True,
440+
"lead_form_type": None,
427441
}
428442
)
429443

@@ -437,6 +451,7 @@ async def process_query(
437451
"has_context": True,
438452
"links": ctx.extracted_links,
439453
"requires_lead": ctx.requires_lead,
454+
"lead_form_type": ctx.lead_form_type,
440455
}
441456
)
442457

@@ -450,14 +465,20 @@ async def process_query_stream(
450465

451466
if not ctx.is_valid:
452467
meta_payload = json.dumps(
453-
{"links": ctx.extracted_links, "requires_lead": False}, ensure_ascii=False
468+
{"links": ctx.extracted_links, "requires_lead": False, "lead_form_type": None},
469+
ensure_ascii=False,
454470
)
455471
yield f"[METADATA] {meta_payload}"
456472
yield json.dumps({"token": ctx.fallback_response}, ensure_ascii=False)
457473
return
458474

459475
meta_payload = json.dumps(
460-
{"links": ctx.extracted_links, "requires_lead": ctx.requires_lead}, ensure_ascii=False
476+
{
477+
"links": ctx.extracted_links,
478+
"requires_lead": ctx.requires_lead,
479+
"lead_form_type": ctx.lead_form_type,
480+
},
481+
ensure_ascii=False,
461482
)
462483
yield f"[METADATA] {meta_payload}"
463484

0 commit comments

Comments
 (0)