ReplyFlow / app /services /chat.py
nvikou's picture
Initial commit
582520a verified
Raw History Blame Contribute Delete
5.81 kB
"""Core chat orchestration service."""
from __future__ import annotations
from typing import Any, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import (
Conversation,
Lead,
Message,
UnansweredQuestion,
)
from app.services import calcom, handoff, leads, llm
from app.services.language import detect_language
from app.services.rag import get_knowledge_store
BOOKING_HINTS = (
"book",
"appointment",
"schedule",
"meeting",
"slot",
"rendez-vous",
"rdv",
"créneau",
"запис",
"встреч",
"слот",
)
GREETINGS = {
"en": (
"Hello! I am ReplyFlow for Nordic Paper Solutions. "
"Ask about products, pricing, delivery, or book a call."
),
"fr": (
"Bonjour ! Je suis ReplyFlow pour Nordic Paper "
"Solutions. Posez vos questions sur les produits, "
"tarifs, livraison, ou réservez un appel."
),
"ru": (
"Здравствуйте! Я ReplyFlow для Nordic Paper "
"Solutions. Спросите о продуктах, ценах, доставке "
"или запишитесь на звонок."
),
}
async def start_conversation(
db: AsyncSession,
*,
channel: str = "web",
ui_language: str = "en",
external_id: Optional[str] = None,
) -> tuple[Conversation, str]:
"""Create conversation + empty lead + greeting."""
lang = ui_language if ui_language in GREETINGS else "en"
conv = Conversation(
channel=channel,
language=lang,
external_id=external_id,
)
db.add(conv)
await db.flush()
db.add(Lead(conversation_id=conv.id))
greeting = GREETINGS[lang]
db.add(
Message(
conversation_id=conv.id,
role="assistant",
content=greeting,
language=lang,
grounded=True,
)
)
await db.commit()
await db.refresh(conv)
return conv, greeting
async def handle_user_message(
db: AsyncSession,
*,
conversation_id: int,
message: str,
ui_language: str = "en",
) -> dict[str, Any]:
"""Process one user turn end-to-end."""
result = await db.execute(
select(Conversation)
.where(Conversation.id == conversation_id)
.options(
selectinload(Conversation.messages),
selectinload(Conversation.lead),
)
)
conv = result.scalar_one()
language = detect_language(message, default=ui_language)
conv.language = language
previous_bot = None
for msg in reversed(conv.messages):
if msg.role == "assistant":
previous_bot = msg.content
break
lead = conv.lead or Lead(conversation_id=conv.id)
if conv.lead is None:
db.add(lead)
await db.flush()
leads.update_lead_from_message(lead, message, previous_bot)
db.add(
Message(
conversation_id=conv.id,
role="user",
content=message,
language=language,
)
)
wants_booking = any(
h in message.lower() for h in BOOKING_HINTS
)
store = get_knowledge_store()
chunks = await store.search(message, top_k=4)
grounded = bool(chunks)
if not grounded:
conv.failure_streak += 1
db.add(
UnansweredQuestion(
conversation_id=conv.id,
question=message,
)
)
else:
conv.failure_streak = 0
history = [
{"role": m.role, "content": m.content}
for m in conv.messages
if m.role in {"user", "assistant"}
]
lead_state = leads.lead_to_dict(lead)
do_handoff, reason = handoff.should_handoff(
message=message,
failure_streak=conv.failure_streak,
grounded=grounded,
)
suggested_slots = None
reply: str
if do_handoff:
conv.handed_off = True
conv.status = "handed_off"
reply = handoff.handoff_message(language, reason)
await handoff.notify_handoff(
conversation_id=conv.id,
reason=reason,
history=history + [{"role": "user", "content": message}],
lead=lead_state,
)
else:
reply, grounded = await llm.generate_reply(
question=message,
language=language,
context_chunks=chunks,
history=history,
lead_state=lead_state,
)
if wants_booking or (
lead.complete and "book" in reply.lower()
):
suggested_slots = await calcom.get_available_slots(3)
offer = {
"en": "Here are 3 available slots:",
"fr": "Voici 3 créneaux disponibles :",
"ru": "Вот 3 свободных слота:",
}
reply += "\n\n" + offer.get(language, offer["en"])
for i, slot in enumerate(suggested_slots, start=1):
reply += f"\n{i}. {slot['label']}"
if grounded and not conv.handed_off:
conv.resolved_without_human = True
db.add(
Message(
conversation_id=conv.id,
role="assistant",
content=reply,
language=language,
grounded=grounded if not do_handoff else None,
)
)
conv.updated_at = conv.updated_at
await db.commit()
return {
"conversation_id": conv.id,
"reply": reply,
"language": language,
"grounded": grounded and not do_handoff,
"lead": leads.lead_to_dict(lead),
"suggested_slots": suggested_slots,
"handed_off": conv.handed_off,
"appointment_booked": conv.appointment_booked,
}