Initial commit
Browse files- .dockerignore +11 -0
- .env.example +24 -0
- .gitignore +11 -0
- Dockerfile +29 -0
- README.md +56 -10
- app/__init__.py +1 -0
- app/api/__init__.py +1 -0
- app/api/appointments.py +105 -0
- app/api/chat.py +47 -0
- app/api/conversations.py +168 -0
- app/api/dashboard.py +90 -0
- app/api/knowledge.py +55 -0
- app/api/whatsapp.py +140 -0
- app/config.py +71 -0
- app/db.py +37 -0
- app/main.py +128 -0
- app/models.py +214 -0
- app/schemas.py +133 -0
- app/services/__init__.py +1 -0
- app/services/calcom.py +163 -0
- app/services/chat.py +215 -0
- app/services/embeddings.py +99 -0
- app/services/handoff.py +117 -0
- app/services/language.py +64 -0
- app/services/leads.py +176 -0
- app/services/llm.py +171 -0
- app/services/rag.py +229 -0
- app/static/chat.html +79 -0
- app/static/css/styles.css +483 -0
- app/static/dashboard.html +87 -0
- app/static/index.html +69 -0
- app/static/js/chat.js +136 -0
- app/static/js/dashboard.js +78 -0
- app/static/widget/replyflow-widget.js +115 -0
- data/chroma/.gitkeep +1 -0
- data/db/.gitkeep +1 -0
- data/knowledge/faq_en.md +34 -0
- data/knowledge/faq_fr.md +34 -0
- data/knowledge/faq_ru.md +34 -0
- data/knowledge/product_page.html +25 -0
- requirements.txt +14 -0
- test/test_questions.py +108 -0
.dockerignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.venv
|
| 3 |
+
venv
|
| 4 |
+
__pycache__
|
| 5 |
+
*.pyc
|
| 6 |
+
.env
|
| 7 |
+
data/db
|
| 8 |
+
data/chroma
|
| 9 |
+
.pytest_cache
|
| 10 |
+
.mypy_cache
|
| 11 |
+
test
|
.env.example
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ReplyFlow — Hugging Face Space secrets
|
| 2 |
+
|
| 3 |
+
# Required for real LLM answers (set as Space Secret)
|
| 4 |
+
OPENAI_API_KEY=
|
| 5 |
+
|
| 6 |
+
# Optional models
|
| 7 |
+
OPENAI_MODEL=gpt-4o-mini
|
| 8 |
+
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
| 9 |
+
|
| 10 |
+
# App
|
| 11 |
+
APP_NAME=ReplyFlow
|
| 12 |
+
APP_ENV=production
|
| 13 |
+
SECRET_KEY=change-me-in-space-secrets
|
| 14 |
+
DEMO_BUSINESS_NAME=Nordic Paper Solutions
|
| 15 |
+
DEMO_TIMEZONE=Europe/Paris
|
| 16 |
+
|
| 17 |
+
# Optional integrations
|
| 18 |
+
CALCOM_API_KEY=
|
| 19 |
+
CALCOM_EVENT_TYPE_ID=
|
| 20 |
+
CALCOM_USERNAME=
|
| 21 |
+
WHATSAPP_TOKEN=
|
| 22 |
+
WHATSAPP_PHONE_NUMBER_ID=
|
| 23 |
+
WHATSAPP_VERIFY_TOKEN=replyflow-verify
|
| 24 |
+
HANDOFF_WEBHOOK_URL=
|
.gitignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.venv/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.pyc
|
| 5 |
+
.pytest_cache/
|
| 6 |
+
data/db/
|
| 7 |
+
data/chroma/
|
| 8 |
+
!data/knowledge/
|
| 9 |
+
*.log
|
| 10 |
+
*.db
|
| 11 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Hugging Face Spaces listens on 7860.
|
| 6 |
+
# JSON vector fallback only (no Chroma sidecar on Spaces).
|
| 7 |
+
ENV PORT=7860 \
|
| 8 |
+
PYTHONUNBUFFERED=1 \
|
| 9 |
+
APP_ENV=production \
|
| 10 |
+
DATABASE_URL=sqlite+aiosqlite:///./data/db/replyflow.db \
|
| 11 |
+
CHROMA_PATH=./data/chroma \
|
| 12 |
+
KNOWLEDGE_PATH=./data/knowledge
|
| 13 |
+
|
| 14 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 15 |
+
build-essential \
|
| 16 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 17 |
+
|
| 18 |
+
COPY requirements.txt ./
|
| 19 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 20 |
+
|
| 21 |
+
COPY . .
|
| 22 |
+
|
| 23 |
+
RUN mkdir -p /app/data/db /app/data/chroma /app/data/knowledge \
|
| 24 |
+
&& chmod -R 777 /app/data
|
| 25 |
+
|
| 26 |
+
EXPOSE 7860
|
| 27 |
+
|
| 28 |
+
CMD ["sh", "-c", \
|
| 29 |
+
"uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}"]
|
README.md
CHANGED
|
@@ -1,10 +1,56 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: ReplyFlow
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: docker
|
| 7 |
-
pinned: false
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: ReplyFlow
|
| 3 |
+
emoji: 💬
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
app_port: 7860
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# ReplyFlow
|
| 12 |
+
|
| 13 |
+
Multilingual customer chatbot for **web** (and WhatsApp-ready):
|
| 14 |
+
grounded RAG answers, lead capture, appointment slots, and
|
| 15 |
+
human handoff.
|
| 16 |
+
|
| 17 |
+
**Demo claim:** 60% of questions handled without a human,
|
| 18 |
+
appointment booking 24/7.
|
| 19 |
+
|
| 20 |
+
## Demo business
|
| 21 |
+
|
| 22 |
+
Fictional SME **Nordic Paper Solutions** (FAQ EN / FR / RU).
|
| 23 |
+
Safe for demos — no real customer data.
|
| 24 |
+
|
| 25 |
+
## How to use
|
| 26 |
+
|
| 27 |
+
1. Open **Live demo** and ask about products, pricing, delivery.
|
| 28 |
+
2. Try booking a call (demo slots if Cal.com is not set).
|
| 29 |
+
3. Check **Dashboard** for conversations and KPIs.
|
| 30 |
+
4. Optional: embed with `<script src="/widget.js" defer></script>`.
|
| 31 |
+
|
| 32 |
+
## Space secrets
|
| 33 |
+
|
| 34 |
+
In **Settings → Variables and secrets**, set:
|
| 35 |
+
|
| 36 |
+
| Name | Value |
|
| 37 |
+
|------|--------|
|
| 38 |
+
| `OPENAI_API_KEY` | your OpenAI key (**Secret**) |
|
| 39 |
+
| `OPENAI_MODEL` | `gpt-4o-mini` |
|
| 40 |
+
| `OPENAI_EMBEDDING_MODEL` | `text-embedding-3-small` |
|
| 41 |
+
|
| 42 |
+
Optional:
|
| 43 |
+
|
| 44 |
+
| Name | Purpose |
|
| 45 |
+
|------|--------|
|
| 46 |
+
| `CALCOM_API_KEY` + `CALCOM_EVENT_TYPE_ID` | real bookings |
|
| 47 |
+
| `WHATSAPP_TOKEN` + `WHATSAPP_PHONE_NUMBER_ID` | WhatsApp channel |
|
| 48 |
+
| `HANDOFF_WEBHOOK_URL` | notify humans on escalation |
|
| 49 |
+
|
| 50 |
+
Without OpenAI, ReplyFlow still runs in **demo mode** (local
|
| 51 |
+
embeddings + template grounded answers).
|
| 52 |
+
|
| 53 |
+
## Stack
|
| 54 |
+
|
| 55 |
+
FastAPI · RAG (JSON vector store on Spaces) · OpenAI · Cal.com
|
| 56 |
+
hooks · WhatsApp webhook · SQLite
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""ReplyFlow application package."""
|
app/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""API routers package."""
|
app/api/appointments.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Appointment booking API."""
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 4 |
+
from sqlalchemy import select
|
| 5 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
+
from sqlalchemy.orm import selectinload
|
| 7 |
+
|
| 8 |
+
from app.db import get_db
|
| 9 |
+
from app.models import Appointment, Conversation, Message
|
| 10 |
+
from app.schemas import AppointmentResponse, BookAppointmentRequest
|
| 11 |
+
from app.services import calcom
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/api/appointments", tags=["appointments"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.get("/slots")
|
| 17 |
+
async def list_slots() -> dict:
|
| 18 |
+
"""List next available slots."""
|
| 19 |
+
slots = await calcom.get_available_slots(3)
|
| 20 |
+
return {"slots": slots}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@router.post("/book", response_model=AppointmentResponse)
|
| 24 |
+
async def book_appointment(
|
| 25 |
+
body: BookAppointmentRequest,
|
| 26 |
+
db: AsyncSession = Depends(get_db),
|
| 27 |
+
) -> AppointmentResponse:
|
| 28 |
+
"""Book a selected slot for a conversation."""
|
| 29 |
+
result = await db.execute(
|
| 30 |
+
select(Conversation)
|
| 31 |
+
.where(Conversation.id == body.conversation_id)
|
| 32 |
+
.options(
|
| 33 |
+
selectinload(Conversation.lead),
|
| 34 |
+
selectinload(Conversation.appointment),
|
| 35 |
+
)
|
| 36 |
+
)
|
| 37 |
+
conv = result.scalar_one_or_none()
|
| 38 |
+
if conv is None:
|
| 39 |
+
raise HTTPException(status_code=404, detail="Not found")
|
| 40 |
+
|
| 41 |
+
if conv.appointment is not None:
|
| 42 |
+
raise HTTPException(
|
| 43 |
+
status_code=400, detail="Already booked"
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
name = body.attendee_name
|
| 47 |
+
email = body.attendee_email
|
| 48 |
+
if conv.lead:
|
| 49 |
+
name = name or conv.lead.name
|
| 50 |
+
email = email or conv.lead.email
|
| 51 |
+
|
| 52 |
+
booked = await calcom.book_slot(
|
| 53 |
+
slot_start=body.slot_start,
|
| 54 |
+
slot_end=body.slot_end,
|
| 55 |
+
attendee_name=name,
|
| 56 |
+
attendee_email=email,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
appt = Appointment(
|
| 60 |
+
conversation_id=conv.id,
|
| 61 |
+
slot_start=booked["slot_start"],
|
| 62 |
+
slot_end=booked["slot_end"],
|
| 63 |
+
attendee_name=name,
|
| 64 |
+
attendee_email=email,
|
| 65 |
+
external_id=booked.get("external_id"),
|
| 66 |
+
status=booked.get("status", "confirmed"),
|
| 67 |
+
)
|
| 68 |
+
conv.appointment_booked = True
|
| 69 |
+
db.add(appt)
|
| 70 |
+
|
| 71 |
+
confirm = {
|
| 72 |
+
"en": (
|
| 73 |
+
f"Your meeting is confirmed for "
|
| 74 |
+
f"{body.slot_start}. See you then!"
|
| 75 |
+
),
|
| 76 |
+
"fr": (
|
| 77 |
+
f"Votre rendez-vous est confirmé pour "
|
| 78 |
+
f"{body.slot_start}. À bientôt !"
|
| 79 |
+
),
|
| 80 |
+
"ru": (
|
| 81 |
+
f"Встреча подтверждена на {body.slot_start}. "
|
| 82 |
+
f"До связи!"
|
| 83 |
+
),
|
| 84 |
+
}
|
| 85 |
+
lang = conv.language if conv.language in confirm else "en"
|
| 86 |
+
message = confirm[lang]
|
| 87 |
+
db.add(
|
| 88 |
+
Message(
|
| 89 |
+
conversation_id=conv.id,
|
| 90 |
+
role="assistant",
|
| 91 |
+
content=message,
|
| 92 |
+
language=lang,
|
| 93 |
+
grounded=True,
|
| 94 |
+
)
|
| 95 |
+
)
|
| 96 |
+
await db.commit()
|
| 97 |
+
await db.refresh(appt)
|
| 98 |
+
|
| 99 |
+
return AppointmentResponse(
|
| 100 |
+
id=appt.id,
|
| 101 |
+
slot_start=appt.slot_start,
|
| 102 |
+
slot_end=appt.slot_end,
|
| 103 |
+
status=appt.status,
|
| 104 |
+
message=message,
|
| 105 |
+
)
|
app/api/chat.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chat API routes."""
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
|
| 6 |
+
from app.db import get_db
|
| 7 |
+
from app.schemas import (
|
| 8 |
+
ChatMessageRequest,
|
| 9 |
+
ChatMessageResponse,
|
| 10 |
+
ChatStartResponse,
|
| 11 |
+
)
|
| 12 |
+
from app.services.chat import handle_user_message, start_conversation
|
| 13 |
+
from app.services.language import normalize_ui_language
|
| 14 |
+
|
| 15 |
+
router = APIRouter(prefix="/api/chat", tags=["chat"])
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@router.post("/start", response_model=ChatStartResponse)
|
| 19 |
+
async def chat_start(
|
| 20 |
+
ui_language: str = "en",
|
| 21 |
+
db: AsyncSession = Depends(get_db),
|
| 22 |
+
) -> ChatStartResponse:
|
| 23 |
+
"""Start a new web conversation."""
|
| 24 |
+
lang = normalize_ui_language(ui_language)
|
| 25 |
+
conv, greeting = await start_conversation(
|
| 26 |
+
db, channel="web", ui_language=lang
|
| 27 |
+
)
|
| 28 |
+
return ChatStartResponse(
|
| 29 |
+
conversation_id=conv.id,
|
| 30 |
+
greeting=greeting,
|
| 31 |
+
language=lang,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.post("/message", response_model=ChatMessageResponse)
|
| 36 |
+
async def chat_message(
|
| 37 |
+
body: ChatMessageRequest,
|
| 38 |
+
db: AsyncSession = Depends(get_db),
|
| 39 |
+
) -> ChatMessageResponse:
|
| 40 |
+
"""Send a user message and receive the bot reply."""
|
| 41 |
+
data = await handle_user_message(
|
| 42 |
+
db,
|
| 43 |
+
conversation_id=body.conversation_id,
|
| 44 |
+
message=body.message,
|
| 45 |
+
ui_language=normalize_ui_language(body.ui_language),
|
| 46 |
+
)
|
| 47 |
+
return ChatMessageResponse(**data)
|
app/api/conversations.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Conversation history API."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import csv
|
| 6 |
+
import io
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 10 |
+
from fastapi.responses import StreamingResponse
|
| 11 |
+
from sqlalchemy import func, select
|
| 12 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 13 |
+
from sqlalchemy.orm import selectinload
|
| 14 |
+
|
| 15 |
+
from app.db import get_db
|
| 16 |
+
from app.models import Conversation, Lead, Message
|
| 17 |
+
from app.schemas import (
|
| 18 |
+
ConversationDetail,
|
| 19 |
+
ConversationSummary,
|
| 20 |
+
MessageOut,
|
| 21 |
+
)
|
| 22 |
+
from app.services.leads import lead_to_dict
|
| 23 |
+
|
| 24 |
+
router = APIRouter(
|
| 25 |
+
prefix="/api/conversations", tags=["conversations"]
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@router.get("", response_model=list[ConversationSummary])
|
| 30 |
+
async def list_conversations(
|
| 31 |
+
db: AsyncSession = Depends(get_db),
|
| 32 |
+
) -> list[ConversationSummary]:
|
| 33 |
+
"""List conversations newest first."""
|
| 34 |
+
msg_count = (
|
| 35 |
+
select(
|
| 36 |
+
Message.conversation_id,
|
| 37 |
+
func.count(Message.id).label("cnt"),
|
| 38 |
+
)
|
| 39 |
+
.group_by(Message.conversation_id)
|
| 40 |
+
.subquery()
|
| 41 |
+
)
|
| 42 |
+
result = await db.execute(
|
| 43 |
+
select(Conversation, Lead, msg_count.c.cnt)
|
| 44 |
+
.outerjoin(Lead, Lead.conversation_id == Conversation.id)
|
| 45 |
+
.outerjoin(
|
| 46 |
+
msg_count, msg_count.c.conversation_id == Conversation.id
|
| 47 |
+
)
|
| 48 |
+
.order_by(Conversation.created_at.desc())
|
| 49 |
+
.limit(200)
|
| 50 |
+
)
|
| 51 |
+
rows = result.all()
|
| 52 |
+
items: list[ConversationSummary] = []
|
| 53 |
+
for conv, lead, count in rows:
|
| 54 |
+
items.append(
|
| 55 |
+
ConversationSummary(
|
| 56 |
+
id=conv.id,
|
| 57 |
+
channel=conv.channel,
|
| 58 |
+
language=conv.language,
|
| 59 |
+
status=conv.status,
|
| 60 |
+
handed_off=conv.handed_off,
|
| 61 |
+
appointment_booked=conv.appointment_booked,
|
| 62 |
+
resolved_without_human=conv.resolved_without_human,
|
| 63 |
+
message_count=int(count or 0),
|
| 64 |
+
created_at=conv.created_at,
|
| 65 |
+
lead_name=lead.name if lead else None,
|
| 66 |
+
)
|
| 67 |
+
)
|
| 68 |
+
return items
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@router.get(
|
| 72 |
+
"/{conversation_id}",
|
| 73 |
+
response_model=ConversationDetail,
|
| 74 |
+
)
|
| 75 |
+
async def get_conversation(
|
| 76 |
+
conversation_id: int,
|
| 77 |
+
db: AsyncSession = Depends(get_db),
|
| 78 |
+
) -> ConversationDetail:
|
| 79 |
+
"""Get one conversation with messages."""
|
| 80 |
+
result = await db.execute(
|
| 81 |
+
select(Conversation)
|
| 82 |
+
.where(Conversation.id == conversation_id)
|
| 83 |
+
.options(
|
| 84 |
+
selectinload(Conversation.messages),
|
| 85 |
+
selectinload(Conversation.lead),
|
| 86 |
+
selectinload(Conversation.appointment),
|
| 87 |
+
)
|
| 88 |
+
)
|
| 89 |
+
conv = result.scalar_one_or_none()
|
| 90 |
+
if conv is None:
|
| 91 |
+
raise HTTPException(status_code=404, detail="Not found")
|
| 92 |
+
|
| 93 |
+
appointment: dict[str, Any] | None = None
|
| 94 |
+
if conv.appointment:
|
| 95 |
+
appointment = {
|
| 96 |
+
"slot_start": conv.appointment.slot_start,
|
| 97 |
+
"slot_end": conv.appointment.slot_end,
|
| 98 |
+
"status": conv.appointment.status,
|
| 99 |
+
"attendee_name": conv.appointment.attendee_name,
|
| 100 |
+
"attendee_email": conv.appointment.attendee_email,
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
return ConversationDetail(
|
| 104 |
+
id=conv.id,
|
| 105 |
+
channel=conv.channel,
|
| 106 |
+
language=conv.language,
|
| 107 |
+
status=conv.status,
|
| 108 |
+
handed_off=conv.handed_off,
|
| 109 |
+
appointment_booked=conv.appointment_booked,
|
| 110 |
+
resolved_without_human=conv.resolved_without_human,
|
| 111 |
+
created_at=conv.created_at,
|
| 112 |
+
messages=[
|
| 113 |
+
MessageOut(
|
| 114 |
+
id=m.id,
|
| 115 |
+
role=m.role,
|
| 116 |
+
content=m.content,
|
| 117 |
+
language=m.language,
|
| 118 |
+
grounded=m.grounded,
|
| 119 |
+
created_at=m.created_at,
|
| 120 |
+
)
|
| 121 |
+
for m in conv.messages
|
| 122 |
+
],
|
| 123 |
+
lead=lead_to_dict(conv.lead),
|
| 124 |
+
appointment=appointment,
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@router.get("/{conversation_id}/export")
|
| 129 |
+
async def export_conversation(
|
| 130 |
+
conversation_id: int,
|
| 131 |
+
db: AsyncSession = Depends(get_db),
|
| 132 |
+
) -> StreamingResponse:
|
| 133 |
+
"""Export conversation messages as CSV."""
|
| 134 |
+
result = await db.execute(
|
| 135 |
+
select(Conversation)
|
| 136 |
+
.where(Conversation.id == conversation_id)
|
| 137 |
+
.options(selectinload(Conversation.messages))
|
| 138 |
+
)
|
| 139 |
+
conv = result.scalar_one_or_none()
|
| 140 |
+
if conv is None:
|
| 141 |
+
raise HTTPException(status_code=404, detail="Not found")
|
| 142 |
+
|
| 143 |
+
buffer = io.StringIO()
|
| 144 |
+
writer = csv.writer(buffer)
|
| 145 |
+
writer.writerow(
|
| 146 |
+
["id", "role", "language", "grounded", "created_at", "content"]
|
| 147 |
+
)
|
| 148 |
+
for m in conv.messages:
|
| 149 |
+
writer.writerow(
|
| 150 |
+
[
|
| 151 |
+
m.id,
|
| 152 |
+
m.role,
|
| 153 |
+
m.language or "",
|
| 154 |
+
m.grounded if m.grounded is not None else "",
|
| 155 |
+
m.created_at.isoformat(),
|
| 156 |
+
m.content,
|
| 157 |
+
]
|
| 158 |
+
)
|
| 159 |
+
buffer.seek(0)
|
| 160 |
+
return StreamingResponse(
|
| 161 |
+
iter([buffer.getvalue()]),
|
| 162 |
+
media_type="text/csv",
|
| 163 |
+
headers={
|
| 164 |
+
"Content-Disposition": (
|
| 165 |
+
f'attachment; filename="conversation_{conversation_id}.csv"'
|
| 166 |
+
)
|
| 167 |
+
},
|
| 168 |
+
)
|
app/api/dashboard.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dashboard metrics API."""
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends
|
| 4 |
+
from sqlalchemy import func, select
|
| 5 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
+
|
| 7 |
+
from app.db import get_db
|
| 8 |
+
from app.models import Conversation, Lead, UnansweredQuestion
|
| 9 |
+
from app.schemas import DashboardStats
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.get("/stats", response_model=DashboardStats)
|
| 15 |
+
async def dashboard_stats(
|
| 16 |
+
db: AsyncSession = Depends(get_db),
|
| 17 |
+
) -> DashboardStats:
|
| 18 |
+
"""Aggregate KPIs for the operator dashboard."""
|
| 19 |
+
total = await db.scalar(
|
| 20 |
+
select(func.count(Conversation.id))
|
| 21 |
+
) or 0
|
| 22 |
+
resolved = await db.scalar(
|
| 23 |
+
select(func.count(Conversation.id)).where(
|
| 24 |
+
Conversation.resolved_without_human.is_(True)
|
| 25 |
+
)
|
| 26 |
+
) or 0
|
| 27 |
+
appointments = await db.scalar(
|
| 28 |
+
select(func.count(Conversation.id)).where(
|
| 29 |
+
Conversation.appointment_booked.is_(True)
|
| 30 |
+
)
|
| 31 |
+
) or 0
|
| 32 |
+
handed = await db.scalar(
|
| 33 |
+
select(func.count(Conversation.id)).where(
|
| 34 |
+
Conversation.handed_off.is_(True)
|
| 35 |
+
)
|
| 36 |
+
) or 0
|
| 37 |
+
unanswered = await db.scalar(
|
| 38 |
+
select(func.count(UnansweredQuestion.id))
|
| 39 |
+
) or 0
|
| 40 |
+
leads_complete = await db.scalar(
|
| 41 |
+
select(func.count(Lead.id)).where(Lead.complete.is_(True))
|
| 42 |
+
) or 0
|
| 43 |
+
|
| 44 |
+
channel_rows = (
|
| 45 |
+
await db.execute(
|
| 46 |
+
select(
|
| 47 |
+
Conversation.channel,
|
| 48 |
+
func.count(Conversation.id),
|
| 49 |
+
).group_by(Conversation.channel)
|
| 50 |
+
)
|
| 51 |
+
).all()
|
| 52 |
+
language_rows = (
|
| 53 |
+
await db.execute(
|
| 54 |
+
select(
|
| 55 |
+
Conversation.language,
|
| 56 |
+
func.count(Conversation.id),
|
| 57 |
+
).group_by(Conversation.language)
|
| 58 |
+
)
|
| 59 |
+
).all()
|
| 60 |
+
|
| 61 |
+
recent = (
|
| 62 |
+
await db.execute(
|
| 63 |
+
select(UnansweredQuestion)
|
| 64 |
+
.order_by(UnansweredQuestion.created_at.desc())
|
| 65 |
+
.limit(10)
|
| 66 |
+
)
|
| 67 |
+
).scalars().all()
|
| 68 |
+
|
| 69 |
+
rate = (resolved / total * 100.0) if total else 0.0
|
| 70 |
+
|
| 71 |
+
return DashboardStats(
|
| 72 |
+
total_conversations=int(total),
|
| 73 |
+
resolved_without_human=int(resolved),
|
| 74 |
+
resolution_rate=round(rate, 1),
|
| 75 |
+
appointments_booked=int(appointments),
|
| 76 |
+
handed_off=int(handed),
|
| 77 |
+
unanswered_count=int(unanswered),
|
| 78 |
+
leads_complete=int(leads_complete),
|
| 79 |
+
by_channel={c: int(n) for c, n in channel_rows},
|
| 80 |
+
by_language={lang: int(n) for lang, n in language_rows},
|
| 81 |
+
recent_unanswered=[
|
| 82 |
+
{
|
| 83 |
+
"id": q.id,
|
| 84 |
+
"conversation_id": q.conversation_id,
|
| 85 |
+
"question": q.question,
|
| 86 |
+
"created_at": q.created_at.isoformat(),
|
| 87 |
+
}
|
| 88 |
+
for q in recent
|
| 89 |
+
],
|
| 90 |
+
)
|
app/api/knowledge.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Knowledge base ingestion API."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends, File, UploadFile
|
| 6 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 7 |
+
|
| 8 |
+
from app.config import get_settings
|
| 9 |
+
from app.db import get_db
|
| 10 |
+
from app.models import KnowledgeDoc
|
| 11 |
+
from app.schemas import KnowledgeIngestResponse
|
| 12 |
+
from app.services.rag import get_knowledge_store, ingest_path
|
| 13 |
+
|
| 14 |
+
router = APIRouter(prefix="/api/knowledge", tags=["knowledge"])
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@router.get("/status")
|
| 18 |
+
async def knowledge_status() -> dict:
|
| 19 |
+
"""Return current knowledge chunk count."""
|
| 20 |
+
store = get_knowledge_store()
|
| 21 |
+
return {"chunk_count": store.chunk_count}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.post(
|
| 25 |
+
"/ingest",
|
| 26 |
+
response_model=KnowledgeIngestResponse,
|
| 27 |
+
)
|
| 28 |
+
async def ingest_upload(
|
| 29 |
+
file: UploadFile = File(...),
|
| 30 |
+
db: AsyncSession = Depends(get_db),
|
| 31 |
+
) -> KnowledgeIngestResponse:
|
| 32 |
+
"""Upload and ingest a knowledge document."""
|
| 33 |
+
settings = get_settings()
|
| 34 |
+
target_dir = Path(settings.knowledge_path)
|
| 35 |
+
target_dir.mkdir(parents=True, exist_ok=True)
|
| 36 |
+
filename = Path(file.filename or "upload.txt").name
|
| 37 |
+
dest = target_dir / filename
|
| 38 |
+
content = await file.read()
|
| 39 |
+
dest.write_bytes(content)
|
| 40 |
+
|
| 41 |
+
source = dest.suffix.lower().lstrip(".") or "text"
|
| 42 |
+
count = await ingest_path(dest, source)
|
| 43 |
+
db.add(
|
| 44 |
+
KnowledgeDoc(
|
| 45 |
+
filename=filename,
|
| 46 |
+
source_type=source,
|
| 47 |
+
chunk_count=count,
|
| 48 |
+
)
|
| 49 |
+
)
|
| 50 |
+
await db.commit()
|
| 51 |
+
return KnowledgeIngestResponse(
|
| 52 |
+
filename=filename,
|
| 53 |
+
chunk_count=count,
|
| 54 |
+
message=f"Ingested {count} chunks from {filename}",
|
| 55 |
+
)
|
app/api/whatsapp.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""WhatsApp Business Cloud API webhook."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
import httpx
|
| 9 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
| 10 |
+
from sqlalchemy import select
|
| 11 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 12 |
+
|
| 13 |
+
from app.config import get_settings
|
| 14 |
+
from app.db import get_db
|
| 15 |
+
from app.models import Conversation
|
| 16 |
+
from app.services.chat import handle_user_message, start_conversation
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
router = APIRouter(prefix="/api/whatsapp", tags=["whatsapp"])
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@router.get("/webhook")
|
| 23 |
+
async def verify_webhook(
|
| 24 |
+
hub_mode: str = Query(alias="hub.mode", default=""),
|
| 25 |
+
hub_verify_token: str = Query(
|
| 26 |
+
alias="hub.verify_token", default=""
|
| 27 |
+
),
|
| 28 |
+
hub_challenge: str = Query(
|
| 29 |
+
alias="hub.challenge", default=""
|
| 30 |
+
),
|
| 31 |
+
) -> Any:
|
| 32 |
+
"""Meta webhook verification handshake."""
|
| 33 |
+
settings = get_settings()
|
| 34 |
+
if (
|
| 35 |
+
hub_mode == "subscribe"
|
| 36 |
+
and hub_verify_token == settings.whatsapp_verify_token
|
| 37 |
+
):
|
| 38 |
+
return int(hub_challenge) if hub_challenge.isdigit() else hub_challenge
|
| 39 |
+
raise HTTPException(status_code=403, detail="Verification failed")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@router.post("/webhook")
|
| 43 |
+
async def receive_webhook(
|
| 44 |
+
request: Request,
|
| 45 |
+
db: AsyncSession = Depends(get_db),
|
| 46 |
+
) -> dict[str, str]:
|
| 47 |
+
"""Receive inbound WhatsApp messages."""
|
| 48 |
+
payload = await request.json()
|
| 49 |
+
settings = get_settings()
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
entries = payload.get("entry", [])
|
| 53 |
+
for entry in entries:
|
| 54 |
+
for change in entry.get("changes", []):
|
| 55 |
+
value = change.get("value", {})
|
| 56 |
+
messages = value.get("messages", [])
|
| 57 |
+
for msg in messages:
|
| 58 |
+
if msg.get("type") != "text":
|
| 59 |
+
continue
|
| 60 |
+
wa_from = msg.get("from", "")
|
| 61 |
+
text = msg.get("text", {}).get("body", "")
|
| 62 |
+
if not wa_from or not text:
|
| 63 |
+
continue
|
| 64 |
+
await _process_incoming(
|
| 65 |
+
db,
|
| 66 |
+
wa_from=wa_from,
|
| 67 |
+
text=text,
|
| 68 |
+
settings=settings,
|
| 69 |
+
)
|
| 70 |
+
except Exception as exc: # noqa: BLE001
|
| 71 |
+
logger.exception("WhatsApp webhook error: %s", exc)
|
| 72 |
+
|
| 73 |
+
return {"status": "ok"}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
async def _process_incoming(
|
| 77 |
+
db: AsyncSession,
|
| 78 |
+
*,
|
| 79 |
+
wa_from: str,
|
| 80 |
+
text: str,
|
| 81 |
+
settings: Any,
|
| 82 |
+
) -> None:
|
| 83 |
+
result = await db.execute(
|
| 84 |
+
select(Conversation).where(
|
| 85 |
+
Conversation.channel == "whatsapp",
|
| 86 |
+
Conversation.external_id == wa_from,
|
| 87 |
+
Conversation.status == "active",
|
| 88 |
+
)
|
| 89 |
+
)
|
| 90 |
+
conv = result.scalar_one_or_none()
|
| 91 |
+
if conv is None:
|
| 92 |
+
conv, _greeting = await start_conversation(
|
| 93 |
+
db,
|
| 94 |
+
channel="whatsapp",
|
| 95 |
+
ui_language="en",
|
| 96 |
+
external_id=wa_from,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
data = await handle_user_message(
|
| 100 |
+
db,
|
| 101 |
+
conversation_id=conv.id,
|
| 102 |
+
message=text,
|
| 103 |
+
ui_language=conv.language or "en",
|
| 104 |
+
)
|
| 105 |
+
await send_whatsapp_text(wa_from, data["reply"])
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
async def send_whatsapp_text(to: str, body: str) -> None:
|
| 109 |
+
"""Send a WhatsApp text message if credentials exist."""
|
| 110 |
+
settings = get_settings()
|
| 111 |
+
if not settings.has_whatsapp:
|
| 112 |
+
logger.info(
|
| 113 |
+
"WhatsApp demo send to=%s body=%s", to, body[:120]
|
| 114 |
+
)
|
| 115 |
+
return
|
| 116 |
+
|
| 117 |
+
url = (
|
| 118 |
+
f"https://graph.facebook.com/v20.0/"
|
| 119 |
+
f"{settings.whatsapp_phone_number_id}/messages"
|
| 120 |
+
)
|
| 121 |
+
headers = {
|
| 122 |
+
"Authorization": f"Bearer {settings.whatsapp_token}",
|
| 123 |
+
"Content-Type": "application/json",
|
| 124 |
+
}
|
| 125 |
+
payload = {
|
| 126 |
+
"messaging_product": "whatsapp",
|
| 127 |
+
"to": to,
|
| 128 |
+
"type": "text",
|
| 129 |
+
"text": {"body": body[:4000]},
|
| 130 |
+
}
|
| 131 |
+
async with httpx.AsyncClient(timeout=20) as client:
|
| 132 |
+
response = await client.post(
|
| 133 |
+
url, headers=headers, json=payload
|
| 134 |
+
)
|
| 135 |
+
if response.status_code >= 400:
|
| 136 |
+
logger.error(
|
| 137 |
+
"WhatsApp send failed: %s %s",
|
| 138 |
+
response.status_code,
|
| 139 |
+
response.text,
|
| 140 |
+
)
|
app/config.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application settings loaded from environment."""
|
| 2 |
+
|
| 3 |
+
from functools import lru_cache
|
| 4 |
+
|
| 5 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class Settings(BaseSettings):
|
| 9 |
+
"""Runtime configuration for ReplyFlow."""
|
| 10 |
+
|
| 11 |
+
model_config = SettingsConfigDict(
|
| 12 |
+
env_file=".env",
|
| 13 |
+
env_file_encoding="utf-8",
|
| 14 |
+
extra="ignore",
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
app_name: str = "ReplyFlow"
|
| 18 |
+
app_env: str = "production"
|
| 19 |
+
secret_key: str = "change-me"
|
| 20 |
+
public_base_url: str = "http://localhost:7860"
|
| 21 |
+
|
| 22 |
+
database_url: str = (
|
| 23 |
+
"sqlite+aiosqlite:///./data/db/replyflow.db"
|
| 24 |
+
)
|
| 25 |
+
chroma_path: str = "./data/chroma"
|
| 26 |
+
knowledge_path: str = "./data/knowledge"
|
| 27 |
+
|
| 28 |
+
openai_api_key: str = ""
|
| 29 |
+
openai_model: str = "gpt-4o-mini"
|
| 30 |
+
openai_embedding_model: str = "text-embedding-3-small"
|
| 31 |
+
|
| 32 |
+
calcom_api_key: str = ""
|
| 33 |
+
calcom_event_type_id: str = ""
|
| 34 |
+
calcom_username: str = ""
|
| 35 |
+
|
| 36 |
+
whatsapp_token: str = ""
|
| 37 |
+
whatsapp_phone_number_id: str = ""
|
| 38 |
+
whatsapp_verify_token: str = "replyflow-verify"
|
| 39 |
+
whatsapp_app_secret: str = ""
|
| 40 |
+
|
| 41 |
+
handoff_webhook_url: str = ""
|
| 42 |
+
|
| 43 |
+
demo_business_name: str = "Nordic Paper Solutions"
|
| 44 |
+
demo_timezone: str = "Europe/Paris"
|
| 45 |
+
|
| 46 |
+
@property
|
| 47 |
+
def has_openai(self) -> bool:
|
| 48 |
+
"""True when an OpenAI API key is configured."""
|
| 49 |
+
return bool(self.openai_api_key.strip())
|
| 50 |
+
|
| 51 |
+
@property
|
| 52 |
+
def has_calcom(self) -> bool:
|
| 53 |
+
"""True when Cal.com credentials are configured."""
|
| 54 |
+
return bool(
|
| 55 |
+
self.calcom_api_key.strip()
|
| 56 |
+
and self.calcom_event_type_id.strip()
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
@property
|
| 60 |
+
def has_whatsapp(self) -> bool:
|
| 61 |
+
"""True when WhatsApp credentials are configured."""
|
| 62 |
+
return bool(
|
| 63 |
+
self.whatsapp_token.strip()
|
| 64 |
+
and self.whatsapp_phone_number_id.strip()
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@lru_cache
|
| 69 |
+
def get_settings() -> Settings:
|
| 70 |
+
"""Return cached settings instance."""
|
| 71 |
+
return Settings()
|
app/db.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Async database session helpers."""
|
| 2 |
+
|
| 3 |
+
from collections.abc import AsyncGenerator
|
| 4 |
+
|
| 5 |
+
from sqlalchemy.ext.asyncio import (
|
| 6 |
+
AsyncSession,
|
| 7 |
+
async_sessionmaker,
|
| 8 |
+
create_async_engine,
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
from app.config import get_settings
|
| 12 |
+
from app.models import Base
|
| 13 |
+
|
| 14 |
+
settings = get_settings()
|
| 15 |
+
|
| 16 |
+
engine = create_async_engine(
|
| 17 |
+
settings.database_url,
|
| 18 |
+
echo=False,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
SessionLocal = async_sessionmaker(
|
| 22 |
+
engine,
|
| 23 |
+
expire_on_commit=False,
|
| 24 |
+
class_=AsyncSession,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
async def init_db() -> None:
|
| 29 |
+
"""Create tables if they do not exist."""
|
| 30 |
+
async with engine.begin() as conn:
|
| 31 |
+
await conn.run_sync(Base.metadata.create_all)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
| 35 |
+
"""Yield a database session."""
|
| 36 |
+
async with SessionLocal() as session:
|
| 37 |
+
yield session
|
app/main.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ReplyFlow FastAPI application entrypoint."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from contextlib import asynccontextmanager
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from fastapi import FastAPI
|
| 10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
from fastapi.responses import FileResponse
|
| 12 |
+
from fastapi.staticfiles import StaticFiles
|
| 13 |
+
|
| 14 |
+
from app.api import (
|
| 15 |
+
appointments,
|
| 16 |
+
chat,
|
| 17 |
+
conversations,
|
| 18 |
+
dashboard,
|
| 19 |
+
knowledge,
|
| 20 |
+
whatsapp,
|
| 21 |
+
)
|
| 22 |
+
from app.config import get_settings
|
| 23 |
+
from app.db import init_db
|
| 24 |
+
from app.schemas import HealthResponse
|
| 25 |
+
from app.services.rag import get_knowledge_store, ingest_directory
|
| 26 |
+
|
| 27 |
+
logging.basicConfig(
|
| 28 |
+
level=logging.INFO,
|
| 29 |
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
| 30 |
+
)
|
| 31 |
+
logger = logging.getLogger("replyflow")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@asynccontextmanager
|
| 35 |
+
async def lifespan(_: FastAPI):
|
| 36 |
+
"""Initialize DB and knowledge base on startup."""
|
| 37 |
+
settings = get_settings()
|
| 38 |
+
Path("data/db").mkdir(parents=True, exist_ok=True)
|
| 39 |
+
Path(settings.chroma_path).mkdir(parents=True, exist_ok=True)
|
| 40 |
+
Path(settings.knowledge_path).mkdir(parents=True, exist_ok=True)
|
| 41 |
+
|
| 42 |
+
await init_db()
|
| 43 |
+
store = get_knowledge_store()
|
| 44 |
+
if store.chunk_count == 0:
|
| 45 |
+
kb_path = Path(settings.knowledge_path)
|
| 46 |
+
count = await ingest_directory(kb_path)
|
| 47 |
+
logger.info("Seeded knowledge base with %s chunks", count)
|
| 48 |
+
else:
|
| 49 |
+
logger.info(
|
| 50 |
+
"Knowledge base already loaded (%s chunks)",
|
| 51 |
+
store.chunk_count,
|
| 52 |
+
)
|
| 53 |
+
yield
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
settings = get_settings()
|
| 57 |
+
app = FastAPI(
|
| 58 |
+
title=settings.app_name,
|
| 59 |
+
version="1.0.0",
|
| 60 |
+
description=(
|
| 61 |
+
"Multilingual customer chatbot for web and WhatsApp "
|
| 62 |
+
"with RAG, lead qualification, and appointments."
|
| 63 |
+
),
|
| 64 |
+
lifespan=lifespan,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
app.add_middleware(
|
| 68 |
+
CORSMiddleware,
|
| 69 |
+
allow_origins=["*"],
|
| 70 |
+
allow_credentials=True,
|
| 71 |
+
allow_methods=["*"],
|
| 72 |
+
allow_headers=["*"],
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
app.include_router(chat.router)
|
| 76 |
+
app.include_router(appointments.router)
|
| 77 |
+
app.include_router(conversations.router)
|
| 78 |
+
app.include_router(dashboard.router)
|
| 79 |
+
app.include_router(knowledge.router)
|
| 80 |
+
app.include_router(whatsapp.router)
|
| 81 |
+
|
| 82 |
+
static_dir = Path(__file__).parent / "static"
|
| 83 |
+
app.mount(
|
| 84 |
+
"/static",
|
| 85 |
+
StaticFiles(directory=str(static_dir)),
|
| 86 |
+
name="static",
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@app.get("/api/health", response_model=HealthResponse)
|
| 91 |
+
async def health() -> HealthResponse:
|
| 92 |
+
"""Health check and integration flags."""
|
| 93 |
+
store = get_knowledge_store()
|
| 94 |
+
return HealthResponse(
|
| 95 |
+
status="ok",
|
| 96 |
+
app=settings.app_name,
|
| 97 |
+
openai=settings.has_openai,
|
| 98 |
+
calcom=settings.has_calcom,
|
| 99 |
+
whatsapp=settings.has_whatsapp,
|
| 100 |
+
knowledge_chunks=store.chunk_count,
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@app.get("/")
|
| 105 |
+
async def home() -> FileResponse:
|
| 106 |
+
"""Landing page."""
|
| 107 |
+
return FileResponse(static_dir / "index.html")
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@app.get("/chat")
|
| 111 |
+
async def chat_page() -> FileResponse:
|
| 112 |
+
"""Full-page chat demo."""
|
| 113 |
+
return FileResponse(static_dir / "chat.html")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@app.get("/dashboard")
|
| 117 |
+
async def dashboard_page() -> FileResponse:
|
| 118 |
+
"""Operator dashboard."""
|
| 119 |
+
return FileResponse(static_dir / "dashboard.html")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@app.get("/widget.js")
|
| 123 |
+
async def widget_script() -> FileResponse:
|
| 124 |
+
"""Embeddable chat widget script."""
|
| 125 |
+
return FileResponse(
|
| 126 |
+
static_dir / "widget" / "replyflow-widget.js",
|
| 127 |
+
media_type="application/javascript",
|
| 128 |
+
)
|
app/models.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLAlchemy models for conversations and leads."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from sqlalchemy import (
|
| 9 |
+
Boolean,
|
| 10 |
+
DateTime,
|
| 11 |
+
ForeignKey,
|
| 12 |
+
Integer,
|
| 13 |
+
String,
|
| 14 |
+
Text,
|
| 15 |
+
)
|
| 16 |
+
from sqlalchemy.orm import (
|
| 17 |
+
DeclarativeBase,
|
| 18 |
+
Mapped,
|
| 19 |
+
mapped_column,
|
| 20 |
+
relationship,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class Base(DeclarativeBase):
|
| 25 |
+
"""Declarative base."""
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class Conversation(Base):
|
| 29 |
+
"""A chat session on web or WhatsApp."""
|
| 30 |
+
|
| 31 |
+
__tablename__ = "conversations"
|
| 32 |
+
|
| 33 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 34 |
+
channel: Mapped[str] = mapped_column(
|
| 35 |
+
String(32), default="web"
|
| 36 |
+
)
|
| 37 |
+
language: Mapped[str] = mapped_column(
|
| 38 |
+
String(8), default="en"
|
| 39 |
+
)
|
| 40 |
+
status: Mapped[str] = mapped_column(
|
| 41 |
+
String(32), default="active"
|
| 42 |
+
)
|
| 43 |
+
external_id: Mapped[Optional[str]] = mapped_column(
|
| 44 |
+
String(128), nullable=True
|
| 45 |
+
)
|
| 46 |
+
failure_streak: Mapped[int] = mapped_column(
|
| 47 |
+
Integer, default=0
|
| 48 |
+
)
|
| 49 |
+
resolved_without_human: Mapped[bool] = mapped_column(
|
| 50 |
+
Boolean, default=False
|
| 51 |
+
)
|
| 52 |
+
handed_off: Mapped[bool] = mapped_column(
|
| 53 |
+
Boolean, default=False
|
| 54 |
+
)
|
| 55 |
+
appointment_booked: Mapped[bool] = mapped_column(
|
| 56 |
+
Boolean, default=False
|
| 57 |
+
)
|
| 58 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 59 |
+
DateTime, default=datetime.utcnow
|
| 60 |
+
)
|
| 61 |
+
updated_at: Mapped[datetime] = mapped_column(
|
| 62 |
+
DateTime,
|
| 63 |
+
default=datetime.utcnow,
|
| 64 |
+
onupdate=datetime.utcnow,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
messages: Mapped[list["Message"]] = relationship(
|
| 68 |
+
back_populates="conversation",
|
| 69 |
+
cascade="all, delete-orphan",
|
| 70 |
+
order_by="Message.created_at",
|
| 71 |
+
)
|
| 72 |
+
lead: Mapped[Optional["Lead"]] = relationship(
|
| 73 |
+
back_populates="conversation",
|
| 74 |
+
uselist=False,
|
| 75 |
+
cascade="all, delete-orphan",
|
| 76 |
+
)
|
| 77 |
+
appointment: Mapped[Optional["Appointment"]] = (
|
| 78 |
+
relationship(
|
| 79 |
+
back_populates="conversation",
|
| 80 |
+
uselist=False,
|
| 81 |
+
cascade="all, delete-orphan",
|
| 82 |
+
)
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class Message(Base):
|
| 87 |
+
"""Single message in a conversation."""
|
| 88 |
+
|
| 89 |
+
__tablename__ = "messages"
|
| 90 |
+
|
| 91 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 92 |
+
conversation_id: Mapped[int] = mapped_column(
|
| 93 |
+
ForeignKey("conversations.id"), index=True
|
| 94 |
+
)
|
| 95 |
+
role: Mapped[str] = mapped_column(String(16))
|
| 96 |
+
content: Mapped[str] = mapped_column(Text)
|
| 97 |
+
language: Mapped[Optional[str]] = mapped_column(
|
| 98 |
+
String(8), nullable=True
|
| 99 |
+
)
|
| 100 |
+
grounded: Mapped[Optional[bool]] = mapped_column(
|
| 101 |
+
Boolean, nullable=True
|
| 102 |
+
)
|
| 103 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 104 |
+
DateTime, default=datetime.utcnow
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
conversation: Mapped["Conversation"] = relationship(
|
| 108 |
+
back_populates="messages"
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class Lead(Base):
|
| 113 |
+
"""Prospect qualification data collected in chat."""
|
| 114 |
+
|
| 115 |
+
__tablename__ = "leads"
|
| 116 |
+
|
| 117 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 118 |
+
conversation_id: Mapped[int] = mapped_column(
|
| 119 |
+
ForeignKey("conversations.id"), unique=True
|
| 120 |
+
)
|
| 121 |
+
name: Mapped[Optional[str]] = mapped_column(
|
| 122 |
+
String(128), nullable=True
|
| 123 |
+
)
|
| 124 |
+
need: Mapped[Optional[str]] = mapped_column(
|
| 125 |
+
Text, nullable=True
|
| 126 |
+
)
|
| 127 |
+
budget: Mapped[Optional[str]] = mapped_column(
|
| 128 |
+
String(64), nullable=True
|
| 129 |
+
)
|
| 130 |
+
contact_channel: Mapped[Optional[str]] = mapped_column(
|
| 131 |
+
String(64), nullable=True
|
| 132 |
+
)
|
| 133 |
+
email: Mapped[Optional[str]] = mapped_column(
|
| 134 |
+
String(256), nullable=True
|
| 135 |
+
)
|
| 136 |
+
phone: Mapped[Optional[str]] = mapped_column(
|
| 137 |
+
String(64), nullable=True
|
| 138 |
+
)
|
| 139 |
+
complete: Mapped[bool] = mapped_column(
|
| 140 |
+
Boolean, default=False
|
| 141 |
+
)
|
| 142 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 143 |
+
DateTime, default=datetime.utcnow
|
| 144 |
+
)
|
| 145 |
+
updated_at: Mapped[datetime] = mapped_column(
|
| 146 |
+
DateTime,
|
| 147 |
+
default=datetime.utcnow,
|
| 148 |
+
onupdate=datetime.utcnow,
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
conversation: Mapped["Conversation"] = relationship(
|
| 152 |
+
back_populates="lead"
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
class Appointment(Base):
|
| 157 |
+
"""Booked meeting slot."""
|
| 158 |
+
|
| 159 |
+
__tablename__ = "appointments"
|
| 160 |
+
|
| 161 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 162 |
+
conversation_id: Mapped[int] = mapped_column(
|
| 163 |
+
ForeignKey("conversations.id"), unique=True
|
| 164 |
+
)
|
| 165 |
+
slot_start: Mapped[str] = mapped_column(String(64))
|
| 166 |
+
slot_end: Mapped[str] = mapped_column(String(64))
|
| 167 |
+
attendee_name: Mapped[Optional[str]] = mapped_column(
|
| 168 |
+
String(128), nullable=True
|
| 169 |
+
)
|
| 170 |
+
attendee_email: Mapped[Optional[str]] = mapped_column(
|
| 171 |
+
String(256), nullable=True
|
| 172 |
+
)
|
| 173 |
+
external_id: Mapped[Optional[str]] = mapped_column(
|
| 174 |
+
String(128), nullable=True
|
| 175 |
+
)
|
| 176 |
+
status: Mapped[str] = mapped_column(
|
| 177 |
+
String(32), default="confirmed"
|
| 178 |
+
)
|
| 179 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 180 |
+
DateTime, default=datetime.utcnow
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
conversation: Mapped["Conversation"] = relationship(
|
| 184 |
+
back_populates="appointment"
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
class UnansweredQuestion(Base):
|
| 189 |
+
"""Question the bot could not answer from KB."""
|
| 190 |
+
|
| 191 |
+
__tablename__ = "unanswered_questions"
|
| 192 |
+
|
| 193 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 194 |
+
conversation_id: Mapped[int] = mapped_column(
|
| 195 |
+
ForeignKey("conversations.id"), index=True
|
| 196 |
+
)
|
| 197 |
+
question: Mapped[str] = mapped_column(Text)
|
| 198 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 199 |
+
DateTime, default=datetime.utcnow
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
class KnowledgeDoc(Base):
|
| 204 |
+
"""Ingested knowledge document metadata."""
|
| 205 |
+
|
| 206 |
+
__tablename__ = "knowledge_docs"
|
| 207 |
+
|
| 208 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 209 |
+
filename: Mapped[str] = mapped_column(String(256))
|
| 210 |
+
source_type: Mapped[str] = mapped_column(String(32))
|
| 211 |
+
chunk_count: Mapped[int] = mapped_column(Integer, default=0)
|
| 212 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 213 |
+
DateTime, default=datetime.utcnow
|
| 214 |
+
)
|
app/schemas.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic request/response schemas."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import Any, Optional
|
| 7 |
+
|
| 8 |
+
from pydantic import BaseModel, Field
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class ChatStartResponse(BaseModel):
|
| 12 |
+
"""New conversation payload."""
|
| 13 |
+
|
| 14 |
+
conversation_id: int
|
| 15 |
+
greeting: str
|
| 16 |
+
language: str
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ChatMessageRequest(BaseModel):
|
| 20 |
+
"""Incoming user message."""
|
| 21 |
+
|
| 22 |
+
conversation_id: int
|
| 23 |
+
message: str = Field(min_length=1, max_length=4000)
|
| 24 |
+
ui_language: str = "en"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ChatMessageResponse(BaseModel):
|
| 28 |
+
"""Bot reply payload."""
|
| 29 |
+
|
| 30 |
+
conversation_id: int
|
| 31 |
+
reply: str
|
| 32 |
+
language: str
|
| 33 |
+
grounded: bool
|
| 34 |
+
lead: Optional[dict[str, Any]] = None
|
| 35 |
+
suggested_slots: Optional[list[dict[str, str]]] = None
|
| 36 |
+
handed_off: bool = False
|
| 37 |
+
appointment_booked: bool = False
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class BookAppointmentRequest(BaseModel):
|
| 41 |
+
"""Confirm a suggested slot."""
|
| 42 |
+
|
| 43 |
+
conversation_id: int
|
| 44 |
+
slot_start: str
|
| 45 |
+
slot_end: str
|
| 46 |
+
attendee_name: Optional[str] = None
|
| 47 |
+
attendee_email: Optional[str] = None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class AppointmentResponse(BaseModel):
|
| 51 |
+
"""Booked appointment confirmation."""
|
| 52 |
+
|
| 53 |
+
id: int
|
| 54 |
+
slot_start: str
|
| 55 |
+
slot_end: str
|
| 56 |
+
status: str
|
| 57 |
+
message: str
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class ConversationSummary(BaseModel):
|
| 61 |
+
"""List item for conversation history."""
|
| 62 |
+
|
| 63 |
+
id: int
|
| 64 |
+
channel: str
|
| 65 |
+
language: str
|
| 66 |
+
status: str
|
| 67 |
+
handed_off: bool
|
| 68 |
+
appointment_booked: bool
|
| 69 |
+
resolved_without_human: bool
|
| 70 |
+
message_count: int
|
| 71 |
+
created_at: datetime
|
| 72 |
+
lead_name: Optional[str] = None
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class MessageOut(BaseModel):
|
| 76 |
+
"""Message in conversation detail."""
|
| 77 |
+
|
| 78 |
+
id: int
|
| 79 |
+
role: str
|
| 80 |
+
content: str
|
| 81 |
+
language: Optional[str]
|
| 82 |
+
grounded: Optional[bool]
|
| 83 |
+
created_at: datetime
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class ConversationDetail(BaseModel):
|
| 87 |
+
"""Full conversation with messages."""
|
| 88 |
+
|
| 89 |
+
id: int
|
| 90 |
+
channel: str
|
| 91 |
+
language: str
|
| 92 |
+
status: str
|
| 93 |
+
handed_off: bool
|
| 94 |
+
appointment_booked: bool
|
| 95 |
+
resolved_without_human: bool
|
| 96 |
+
created_at: datetime
|
| 97 |
+
messages: list[MessageOut]
|
| 98 |
+
lead: Optional[dict[str, Any]] = None
|
| 99 |
+
appointment: Optional[dict[str, Any]] = None
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class DashboardStats(BaseModel):
|
| 103 |
+
"""Dashboard KPI metrics."""
|
| 104 |
+
|
| 105 |
+
total_conversations: int
|
| 106 |
+
resolved_without_human: int
|
| 107 |
+
resolution_rate: float
|
| 108 |
+
appointments_booked: int
|
| 109 |
+
handed_off: int
|
| 110 |
+
unanswered_count: int
|
| 111 |
+
leads_complete: int
|
| 112 |
+
by_channel: dict[str, int]
|
| 113 |
+
by_language: dict[str, int]
|
| 114 |
+
recent_unanswered: list[dict[str, Any]]
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class KnowledgeIngestResponse(BaseModel):
|
| 118 |
+
"""Result of knowledge ingestion."""
|
| 119 |
+
|
| 120 |
+
filename: str
|
| 121 |
+
chunk_count: int
|
| 122 |
+
message: str
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class HealthResponse(BaseModel):
|
| 126 |
+
"""Service health and feature flags."""
|
| 127 |
+
|
| 128 |
+
status: str
|
| 129 |
+
app: str
|
| 130 |
+
openai: bool
|
| 131 |
+
calcom: bool
|
| 132 |
+
whatsapp: bool
|
| 133 |
+
knowledge_chunks: int
|
app/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Services package."""
|
app/services/calcom.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cal.com appointment integration with demo fallback."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from datetime import datetime, timedelta, timezone
|
| 7 |
+
from typing import Any, Optional
|
| 8 |
+
from uuid import uuid4
|
| 9 |
+
|
| 10 |
+
import httpx
|
| 11 |
+
|
| 12 |
+
from app.config import get_settings
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
async def get_available_slots(
|
| 18 |
+
count: int = 3,
|
| 19 |
+
) -> list[dict[str, str]]:
|
| 20 |
+
"""Return upcoming available slots."""
|
| 21 |
+
settings = get_settings()
|
| 22 |
+
if settings.has_calcom:
|
| 23 |
+
try:
|
| 24 |
+
slots = await _fetch_calcom_slots(count)
|
| 25 |
+
if slots:
|
| 26 |
+
return slots
|
| 27 |
+
except Exception as exc: # noqa: BLE001
|
| 28 |
+
logger.exception("Cal.com slots failed: %s", exc)
|
| 29 |
+
return _demo_slots(count)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def book_slot(
|
| 33 |
+
*,
|
| 34 |
+
slot_start: str,
|
| 35 |
+
slot_end: str,
|
| 36 |
+
attendee_name: Optional[str],
|
| 37 |
+
attendee_email: Optional[str],
|
| 38 |
+
) -> dict[str, Any]:
|
| 39 |
+
"""Create a booking via Cal.com or demo store."""
|
| 40 |
+
settings = get_settings()
|
| 41 |
+
if settings.has_calcom:
|
| 42 |
+
try:
|
| 43 |
+
return await _book_calcom(
|
| 44 |
+
slot_start=slot_start,
|
| 45 |
+
slot_end=slot_end,
|
| 46 |
+
attendee_name=attendee_name or "Guest",
|
| 47 |
+
attendee_email=attendee_email
|
| 48 |
+
or "guest@example.com",
|
| 49 |
+
)
|
| 50 |
+
except Exception as exc: # noqa: BLE001
|
| 51 |
+
logger.exception("Cal.com booking failed: %s", exc)
|
| 52 |
+
|
| 53 |
+
return {
|
| 54 |
+
"external_id": f"demo-{uuid4().hex[:10]}",
|
| 55 |
+
"slot_start": slot_start,
|
| 56 |
+
"slot_end": slot_end,
|
| 57 |
+
"status": "confirmed",
|
| 58 |
+
"demo": True,
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _demo_slots(count: int) -> list[dict[str, str]]:
|
| 63 |
+
now = datetime.now(timezone.utc).replace(
|
| 64 |
+
minute=0, second=0, microsecond=0
|
| 65 |
+
)
|
| 66 |
+
slots: list[dict[str, str]] = []
|
| 67 |
+
cursor = now + timedelta(days=1)
|
| 68 |
+
while len(slots) < count:
|
| 69 |
+
if cursor.weekday() < 5 and cursor.hour in (10, 14, 16):
|
| 70 |
+
end = cursor + timedelta(minutes=30)
|
| 71 |
+
slots.append(
|
| 72 |
+
{
|
| 73 |
+
"start": cursor.isoformat(),
|
| 74 |
+
"end": end.isoformat(),
|
| 75 |
+
"label": cursor.strftime(
|
| 76 |
+
"%a %d %b %Y · %H:%M UTC"
|
| 77 |
+
),
|
| 78 |
+
}
|
| 79 |
+
)
|
| 80 |
+
cursor += timedelta(hours=2)
|
| 81 |
+
return slots
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
async def _fetch_calcom_slots(count: int) -> list[dict[str, str]]:
|
| 85 |
+
settings = get_settings()
|
| 86 |
+
start = datetime.now(timezone.utc)
|
| 87 |
+
end = start + timedelta(days=14)
|
| 88 |
+
url = "https://api.cal.com/v1/slots"
|
| 89 |
+
params = {
|
| 90 |
+
"apiKey": settings.calcom_api_key,
|
| 91 |
+
"eventTypeId": settings.calcom_event_type_id,
|
| 92 |
+
"startTime": start.isoformat(),
|
| 93 |
+
"endTime": end.isoformat(),
|
| 94 |
+
}
|
| 95 |
+
async with httpx.AsyncClient(timeout=20) as client:
|
| 96 |
+
response = await client.get(url, params=params)
|
| 97 |
+
response.raise_for_status()
|
| 98 |
+
data = response.json()
|
| 99 |
+
|
| 100 |
+
slots: list[dict[str, str]] = []
|
| 101 |
+
raw_slots = data.get("slots") or data
|
| 102 |
+
if isinstance(raw_slots, dict):
|
| 103 |
+
for day_slots in raw_slots.values():
|
| 104 |
+
if not isinstance(day_slots, list):
|
| 105 |
+
continue
|
| 106 |
+
for item in day_slots:
|
| 107 |
+
start_s = item.get("time") or item.get("start")
|
| 108 |
+
if not start_s:
|
| 109 |
+
continue
|
| 110 |
+
start_dt = datetime.fromisoformat(
|
| 111 |
+
start_s.replace("Z", "+00:00")
|
| 112 |
+
)
|
| 113 |
+
end_dt = start_dt + timedelta(minutes=30)
|
| 114 |
+
slots.append(
|
| 115 |
+
{
|
| 116 |
+
"start": start_dt.isoformat(),
|
| 117 |
+
"end": end_dt.isoformat(),
|
| 118 |
+
"label": start_dt.strftime(
|
| 119 |
+
"%a %d %b %Y · %H:%M UTC"
|
| 120 |
+
),
|
| 121 |
+
}
|
| 122 |
+
)
|
| 123 |
+
if len(slots) >= count:
|
| 124 |
+
return slots
|
| 125 |
+
return slots[:count]
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
async def _book_calcom(
|
| 129 |
+
*,
|
| 130 |
+
slot_start: str,
|
| 131 |
+
slot_end: str,
|
| 132 |
+
attendee_name: str,
|
| 133 |
+
attendee_email: str,
|
| 134 |
+
) -> dict[str, Any]:
|
| 135 |
+
settings = get_settings()
|
| 136 |
+
url = "https://api.cal.com/v1/bookings"
|
| 137 |
+
payload = {
|
| 138 |
+
"eventTypeId": int(settings.calcom_event_type_id),
|
| 139 |
+
"start": slot_start,
|
| 140 |
+
"responses": {
|
| 141 |
+
"name": attendee_name,
|
| 142 |
+
"email": attendee_email,
|
| 143 |
+
},
|
| 144 |
+
"timeZone": settings.demo_timezone,
|
| 145 |
+
"language": "en",
|
| 146 |
+
"metadata": {},
|
| 147 |
+
}
|
| 148 |
+
params = {"apiKey": settings.calcom_api_key}
|
| 149 |
+
async with httpx.AsyncClient(timeout=20) as client:
|
| 150 |
+
response = await client.post(
|
| 151 |
+
url, params=params, json=payload
|
| 152 |
+
)
|
| 153 |
+
response.raise_for_status()
|
| 154 |
+
data = response.json()
|
| 155 |
+
return {
|
| 156 |
+
"external_id": str(
|
| 157 |
+
data.get("id") or data.get("uid") or uuid4()
|
| 158 |
+
),
|
| 159 |
+
"slot_start": slot_start,
|
| 160 |
+
"slot_end": slot_end,
|
| 161 |
+
"status": "confirmed",
|
| 162 |
+
"demo": False,
|
| 163 |
+
}
|
app/services/chat.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core chat orchestration service."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any, Optional
|
| 6 |
+
|
| 7 |
+
from sqlalchemy import select
|
| 8 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
+
from sqlalchemy.orm import selectinload
|
| 10 |
+
|
| 11 |
+
from app.models import (
|
| 12 |
+
Conversation,
|
| 13 |
+
Lead,
|
| 14 |
+
Message,
|
| 15 |
+
UnansweredQuestion,
|
| 16 |
+
)
|
| 17 |
+
from app.services import calcom, handoff, leads, llm
|
| 18 |
+
from app.services.language import detect_language
|
| 19 |
+
from app.services.rag import get_knowledge_store
|
| 20 |
+
|
| 21 |
+
BOOKING_HINTS = (
|
| 22 |
+
"book",
|
| 23 |
+
"appointment",
|
| 24 |
+
"schedule",
|
| 25 |
+
"meeting",
|
| 26 |
+
"slot",
|
| 27 |
+
"rendez-vous",
|
| 28 |
+
"rdv",
|
| 29 |
+
"créneau",
|
| 30 |
+
"запис",
|
| 31 |
+
"встреч",
|
| 32 |
+
"слот",
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
GREETINGS = {
|
| 37 |
+
"en": (
|
| 38 |
+
"Hello! I am ReplyFlow for Nordic Paper Solutions. "
|
| 39 |
+
"Ask about products, pricing, delivery, or book a call."
|
| 40 |
+
),
|
| 41 |
+
"fr": (
|
| 42 |
+
"Bonjour ! Je suis ReplyFlow pour Nordic Paper "
|
| 43 |
+
"Solutions. Posez vos questions sur les produits, "
|
| 44 |
+
"tarifs, livraison, ou réservez un appel."
|
| 45 |
+
),
|
| 46 |
+
"ru": (
|
| 47 |
+
"Здравствуйте! Я ReplyFlow для Nordic Paper "
|
| 48 |
+
"Solutions. Спросите о продуктах, ценах, доставке "
|
| 49 |
+
"или запишитесь на звонок."
|
| 50 |
+
),
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
async def start_conversation(
|
| 55 |
+
db: AsyncSession,
|
| 56 |
+
*,
|
| 57 |
+
channel: str = "web",
|
| 58 |
+
ui_language: str = "en",
|
| 59 |
+
external_id: Optional[str] = None,
|
| 60 |
+
) -> tuple[Conversation, str]:
|
| 61 |
+
"""Create conversation + empty lead + greeting."""
|
| 62 |
+
lang = ui_language if ui_language in GREETINGS else "en"
|
| 63 |
+
conv = Conversation(
|
| 64 |
+
channel=channel,
|
| 65 |
+
language=lang,
|
| 66 |
+
external_id=external_id,
|
| 67 |
+
)
|
| 68 |
+
db.add(conv)
|
| 69 |
+
await db.flush()
|
| 70 |
+
db.add(Lead(conversation_id=conv.id))
|
| 71 |
+
greeting = GREETINGS[lang]
|
| 72 |
+
db.add(
|
| 73 |
+
Message(
|
| 74 |
+
conversation_id=conv.id,
|
| 75 |
+
role="assistant",
|
| 76 |
+
content=greeting,
|
| 77 |
+
language=lang,
|
| 78 |
+
grounded=True,
|
| 79 |
+
)
|
| 80 |
+
)
|
| 81 |
+
await db.commit()
|
| 82 |
+
await db.refresh(conv)
|
| 83 |
+
return conv, greeting
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
async def handle_user_message(
|
| 87 |
+
db: AsyncSession,
|
| 88 |
+
*,
|
| 89 |
+
conversation_id: int,
|
| 90 |
+
message: str,
|
| 91 |
+
ui_language: str = "en",
|
| 92 |
+
) -> dict[str, Any]:
|
| 93 |
+
"""Process one user turn end-to-end."""
|
| 94 |
+
result = await db.execute(
|
| 95 |
+
select(Conversation)
|
| 96 |
+
.where(Conversation.id == conversation_id)
|
| 97 |
+
.options(
|
| 98 |
+
selectinload(Conversation.messages),
|
| 99 |
+
selectinload(Conversation.lead),
|
| 100 |
+
)
|
| 101 |
+
)
|
| 102 |
+
conv = result.scalar_one()
|
| 103 |
+
language = detect_language(message, default=ui_language)
|
| 104 |
+
conv.language = language
|
| 105 |
+
|
| 106 |
+
previous_bot = None
|
| 107 |
+
for msg in reversed(conv.messages):
|
| 108 |
+
if msg.role == "assistant":
|
| 109 |
+
previous_bot = msg.content
|
| 110 |
+
break
|
| 111 |
+
|
| 112 |
+
lead = conv.lead or Lead(conversation_id=conv.id)
|
| 113 |
+
if conv.lead is None:
|
| 114 |
+
db.add(lead)
|
| 115 |
+
await db.flush()
|
| 116 |
+
leads.update_lead_from_message(lead, message, previous_bot)
|
| 117 |
+
|
| 118 |
+
db.add(
|
| 119 |
+
Message(
|
| 120 |
+
conversation_id=conv.id,
|
| 121 |
+
role="user",
|
| 122 |
+
content=message,
|
| 123 |
+
language=language,
|
| 124 |
+
)
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
wants_booking = any(
|
| 128 |
+
h in message.lower() for h in BOOKING_HINTS
|
| 129 |
+
)
|
| 130 |
+
store = get_knowledge_store()
|
| 131 |
+
chunks = await store.search(message, top_k=4)
|
| 132 |
+
grounded = bool(chunks)
|
| 133 |
+
|
| 134 |
+
if not grounded:
|
| 135 |
+
conv.failure_streak += 1
|
| 136 |
+
db.add(
|
| 137 |
+
UnansweredQuestion(
|
| 138 |
+
conversation_id=conv.id,
|
| 139 |
+
question=message,
|
| 140 |
+
)
|
| 141 |
+
)
|
| 142 |
+
else:
|
| 143 |
+
conv.failure_streak = 0
|
| 144 |
+
|
| 145 |
+
history = [
|
| 146 |
+
{"role": m.role, "content": m.content}
|
| 147 |
+
for m in conv.messages
|
| 148 |
+
if m.role in {"user", "assistant"}
|
| 149 |
+
]
|
| 150 |
+
lead_state = leads.lead_to_dict(lead)
|
| 151 |
+
|
| 152 |
+
do_handoff, reason = handoff.should_handoff(
|
| 153 |
+
message=message,
|
| 154 |
+
failure_streak=conv.failure_streak,
|
| 155 |
+
grounded=grounded,
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
suggested_slots = None
|
| 159 |
+
reply: str
|
| 160 |
+
if do_handoff:
|
| 161 |
+
conv.handed_off = True
|
| 162 |
+
conv.status = "handed_off"
|
| 163 |
+
reply = handoff.handoff_message(language, reason)
|
| 164 |
+
await handoff.notify_handoff(
|
| 165 |
+
conversation_id=conv.id,
|
| 166 |
+
reason=reason,
|
| 167 |
+
history=history + [{"role": "user", "content": message}],
|
| 168 |
+
lead=lead_state,
|
| 169 |
+
)
|
| 170 |
+
else:
|
| 171 |
+
reply, grounded = await llm.generate_reply(
|
| 172 |
+
question=message,
|
| 173 |
+
language=language,
|
| 174 |
+
context_chunks=chunks,
|
| 175 |
+
history=history,
|
| 176 |
+
lead_state=lead_state,
|
| 177 |
+
)
|
| 178 |
+
if wants_booking or (
|
| 179 |
+
lead.complete and "book" in reply.lower()
|
| 180 |
+
):
|
| 181 |
+
suggested_slots = await calcom.get_available_slots(3)
|
| 182 |
+
offer = {
|
| 183 |
+
"en": "Here are 3 available slots:",
|
| 184 |
+
"fr": "Voici 3 créneaux disponibles :",
|
| 185 |
+
"ru": "Вот 3 свободных слота:",
|
| 186 |
+
}
|
| 187 |
+
reply += "\n\n" + offer.get(language, offer["en"])
|
| 188 |
+
for i, slot in enumerate(suggested_slots, start=1):
|
| 189 |
+
reply += f"\n{i}. {slot['label']}"
|
| 190 |
+
|
| 191 |
+
if grounded and not conv.handed_off:
|
| 192 |
+
conv.resolved_without_human = True
|
| 193 |
+
|
| 194 |
+
db.add(
|
| 195 |
+
Message(
|
| 196 |
+
conversation_id=conv.id,
|
| 197 |
+
role="assistant",
|
| 198 |
+
content=reply,
|
| 199 |
+
language=language,
|
| 200 |
+
grounded=grounded if not do_handoff else None,
|
| 201 |
+
)
|
| 202 |
+
)
|
| 203 |
+
conv.updated_at = conv.updated_at
|
| 204 |
+
await db.commit()
|
| 205 |
+
|
| 206 |
+
return {
|
| 207 |
+
"conversation_id": conv.id,
|
| 208 |
+
"reply": reply,
|
| 209 |
+
"language": language,
|
| 210 |
+
"grounded": grounded and not do_handoff,
|
| 211 |
+
"lead": leads.lead_to_dict(lead),
|
| 212 |
+
"suggested_slots": suggested_slots,
|
| 213 |
+
"handed_off": conv.handed_off,
|
| 214 |
+
"appointment_booked": conv.appointment_booked,
|
| 215 |
+
}
|
app/services/embeddings.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Embedding helpers with OpenAI or local fallback."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import re
|
| 7 |
+
from typing import Sequence
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
from app.config import get_settings
|
| 12 |
+
|
| 13 |
+
_DIM = 384
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _tokenize(text: str) -> list[str]:
|
| 17 |
+
return re.findall(r"[\w\-]+", text.lower(), flags=re.UNICODE)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _local_embed(text: str) -> list[float]:
|
| 21 |
+
"""Deterministic bag-of-tokens embedding for demo mode."""
|
| 22 |
+
vec = np.zeros(_DIM, dtype=np.float32)
|
| 23 |
+
tokens = _tokenize(text)
|
| 24 |
+
if not tokens:
|
| 25 |
+
return vec.tolist()
|
| 26 |
+
for token in tokens:
|
| 27 |
+
digest = hashlib.sha256(token.encode("utf-8")).digest()
|
| 28 |
+
idx = int.from_bytes(digest[:4], "little") % _DIM
|
| 29 |
+
sign = 1.0 if digest[4] % 2 == 0 else -1.0
|
| 30 |
+
vec[idx] += sign
|
| 31 |
+
norm = float(np.linalg.norm(vec))
|
| 32 |
+
if norm > 0:
|
| 33 |
+
vec /= norm
|
| 34 |
+
return vec.tolist()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
async def embed_texts(texts: Sequence[str]) -> list[list[float]]:
|
| 38 |
+
"""Embed texts via OpenAI or local fallback."""
|
| 39 |
+
settings = get_settings()
|
| 40 |
+
if not settings.has_openai:
|
| 41 |
+
return [_local_embed(t) for t in texts]
|
| 42 |
+
|
| 43 |
+
from openai import AsyncOpenAI
|
| 44 |
+
|
| 45 |
+
client = AsyncOpenAI(api_key=settings.openai_api_key)
|
| 46 |
+
response = await client.embeddings.create(
|
| 47 |
+
model=settings.openai_embedding_model,
|
| 48 |
+
input=list(texts),
|
| 49 |
+
)
|
| 50 |
+
return [item.embedding for item in response.data]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def cosine_similarity(a: Sequence[float], b: Sequence[float]) -> float:
|
| 54 |
+
"""Cosine similarity between two vectors."""
|
| 55 |
+
va = np.asarray(a, dtype=np.float32)
|
| 56 |
+
vb = np.asarray(b, dtype=np.float32)
|
| 57 |
+
denom = float(np.linalg.norm(va) * np.linalg.norm(vb))
|
| 58 |
+
if denom == 0:
|
| 59 |
+
return 0.0
|
| 60 |
+
return float(np.dot(va, vb) / denom)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def token_overlap_score(query: str, document: str) -> float:
|
| 64 |
+
"""Simple keyword overlap for offline demo retrieval."""
|
| 65 |
+
q_tokens = {
|
| 66 |
+
t for t in _tokenize(query) if len(t) > 2
|
| 67 |
+
}
|
| 68 |
+
if not q_tokens:
|
| 69 |
+
return 0.0
|
| 70 |
+
d_tokens = set(_tokenize(document))
|
| 71 |
+
if not d_tokens:
|
| 72 |
+
return 0.0
|
| 73 |
+
hits = q_tokens & d_tokens
|
| 74 |
+
return len(hits) / len(q_tokens)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def chunk_text(
|
| 78 |
+
text: str,
|
| 79 |
+
chunk_size: int = 700,
|
| 80 |
+
overlap: int = 120,
|
| 81 |
+
) -> list[str]:
|
| 82 |
+
"""Split text into overlapping chunks."""
|
| 83 |
+
cleaned = re.sub(r"\s+", " ", text).strip()
|
| 84 |
+
if not cleaned:
|
| 85 |
+
return []
|
| 86 |
+
if len(cleaned) <= chunk_size:
|
| 87 |
+
return [cleaned]
|
| 88 |
+
|
| 89 |
+
chunks: list[str] = []
|
| 90 |
+
start = 0
|
| 91 |
+
while start < len(cleaned):
|
| 92 |
+
end = min(len(cleaned), start + chunk_size)
|
| 93 |
+
chunk = cleaned[start:end].strip()
|
| 94 |
+
if chunk:
|
| 95 |
+
chunks.append(chunk)
|
| 96 |
+
if end >= len(cleaned):
|
| 97 |
+
break
|
| 98 |
+
start = max(0, end - overlap)
|
| 99 |
+
return chunks
|
app/services/handoff.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Human handoff detection and notification."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
import httpx
|
| 9 |
+
|
| 10 |
+
from app.config import get_settings
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
SENSITIVE_HINTS = (
|
| 15 |
+
"complaint",
|
| 16 |
+
"réclamation",
|
| 17 |
+
"reclamation",
|
| 18 |
+
"litige",
|
| 19 |
+
"lawsuit",
|
| 20 |
+
"lawyer",
|
| 21 |
+
"avocat",
|
| 22 |
+
"refund",
|
| 23 |
+
"remboursement",
|
| 24 |
+
"scam",
|
| 25 |
+
"fraud",
|
| 26 |
+
"мошен",
|
| 27 |
+
"жалоб",
|
| 28 |
+
"претенз",
|
| 29 |
+
"возврат",
|
| 30 |
+
"суд",
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
HANDOFF_PHRASES = (
|
| 34 |
+
"human",
|
| 35 |
+
"agent",
|
| 36 |
+
"operator",
|
| 37 |
+
"speak to someone",
|
| 38 |
+
"real person",
|
| 39 |
+
"humain",
|
| 40 |
+
"conseiller",
|
| 41 |
+
"parler à quelqu'un",
|
| 42 |
+
"оператор",
|
| 43 |
+
"человек",
|
| 44 |
+
"живой",
|
| 45 |
+
"специалист",
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def should_handoff(
|
| 50 |
+
*,
|
| 51 |
+
message: str,
|
| 52 |
+
failure_streak: int,
|
| 53 |
+
grounded: bool,
|
| 54 |
+
) -> tuple[bool, str]:
|
| 55 |
+
"""Decide whether to escalate to a human."""
|
| 56 |
+
lowered = message.lower()
|
| 57 |
+
if any(p in lowered for p in HANDOFF_PHRASES):
|
| 58 |
+
return True, "explicit_request"
|
| 59 |
+
if any(p in lowered for p in SENSITIVE_HINTS):
|
| 60 |
+
return True, "sensitive_topic"
|
| 61 |
+
if not grounded and failure_streak >= 2:
|
| 62 |
+
return True, "consecutive_failures"
|
| 63 |
+
return False, ""
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
async def notify_handoff(
|
| 67 |
+
*,
|
| 68 |
+
conversation_id: int,
|
| 69 |
+
reason: str,
|
| 70 |
+
history: list[dict[str, str]],
|
| 71 |
+
lead: dict[str, Any],
|
| 72 |
+
) -> None:
|
| 73 |
+
"""Send handoff payload to webhook if configured."""
|
| 74 |
+
settings = get_settings()
|
| 75 |
+
payload = {
|
| 76 |
+
"conversation_id": conversation_id,
|
| 77 |
+
"reason": reason,
|
| 78 |
+
"lead": lead,
|
| 79 |
+
"history": history[-20:],
|
| 80 |
+
"app": settings.app_name,
|
| 81 |
+
}
|
| 82 |
+
if not settings.handoff_webhook_url:
|
| 83 |
+
logger.info(
|
| 84 |
+
"Handoff (no webhook): conversation=%s reason=%s",
|
| 85 |
+
conversation_id,
|
| 86 |
+
reason,
|
| 87 |
+
)
|
| 88 |
+
return
|
| 89 |
+
try:
|
| 90 |
+
async with httpx.AsyncClient(timeout=10) as client:
|
| 91 |
+
await client.post(
|
| 92 |
+
settings.handoff_webhook_url, json=payload
|
| 93 |
+
)
|
| 94 |
+
except Exception as exc: # noqa: BLE001
|
| 95 |
+
logger.exception("Handoff webhook failed: %s", exc)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def handoff_message(language: str, reason: str) -> str:
|
| 99 |
+
"""User-facing handoff acknowledgement."""
|
| 100 |
+
messages = {
|
| 101 |
+
"en": (
|
| 102 |
+
"I am transferring you to a human teammate. "
|
| 103 |
+
"They will see this conversation and follow up "
|
| 104 |
+
f"shortly. (reason: {reason.replace('_', ' ')})"
|
| 105 |
+
),
|
| 106 |
+
"fr": (
|
| 107 |
+
"Je vous transfère à un conseiller humain. "
|
| 108 |
+
"Il verra cet échange et vous répondra bientôt. "
|
| 109 |
+
f"(motif : {reason.replace('_', ' ')})"
|
| 110 |
+
),
|
| 111 |
+
"ru": (
|
| 112 |
+
"Передаю вас живому специалисту. Он увидит "
|
| 113 |
+
"этот диалог и скоро свяжется с вами. "
|
| 114 |
+
f"(причина: {reason.replace('_', ' ')})"
|
| 115 |
+
),
|
| 116 |
+
}
|
| 117 |
+
return messages.get(language, messages["en"])
|
app/services/language.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Language detection helpers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from langdetect import DetectorFactory, detect
|
| 6 |
+
from langdetect.lang_detect_exception import (
|
| 7 |
+
LangDetectException,
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
DetectorFactory.seed = 0
|
| 11 |
+
|
| 12 |
+
SUPPORTED = {"en", "fr", "ru"}
|
| 13 |
+
|
| 14 |
+
_FALLBACK_HINTS = {
|
| 15 |
+
"fr": (
|
| 16 |
+
"bonjour",
|
| 17 |
+
"merci",
|
| 18 |
+
"prix",
|
| 19 |
+
"rendez-vous",
|
| 20 |
+
"horaire",
|
| 21 |
+
"livraison",
|
| 22 |
+
"devis",
|
| 23 |
+
),
|
| 24 |
+
"ru": (
|
| 25 |
+
"привет",
|
| 26 |
+
"спасибо",
|
| 27 |
+
"цена",
|
| 28 |
+
"доставка",
|
| 29 |
+
"запись",
|
| 30 |
+
"стоимость",
|
| 31 |
+
"здравствуйте",
|
| 32 |
+
),
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def detect_language(text: str, default: str = "en") -> str:
|
| 37 |
+
"""Detect message language among en/fr/ru."""
|
| 38 |
+
cleaned = (text or "").strip()
|
| 39 |
+
if not cleaned:
|
| 40 |
+
return default
|
| 41 |
+
|
| 42 |
+
lowered = cleaned.lower()
|
| 43 |
+
for lang, hints in _FALLBACK_HINTS.items():
|
| 44 |
+
if any(h in lowered for h in hints):
|
| 45 |
+
return lang
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
code = detect(cleaned)
|
| 49 |
+
except LangDetectException:
|
| 50 |
+
return default
|
| 51 |
+
|
| 52 |
+
if code.startswith("ru"):
|
| 53 |
+
return "ru"
|
| 54 |
+
if code.startswith("fr"):
|
| 55 |
+
return "fr"
|
| 56 |
+
if code.startswith("en"):
|
| 57 |
+
return "en"
|
| 58 |
+
return default
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def normalize_ui_language(code: str) -> str:
|
| 62 |
+
"""Clamp UI language to supported codes."""
|
| 63 |
+
value = (code or "en").lower()[:2]
|
| 64 |
+
return value if value in SUPPORTED else "en"
|
app/services/leads.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Conversational lead qualification."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
from typing import Any, Optional
|
| 7 |
+
|
| 8 |
+
from app.models import Lead
|
| 9 |
+
|
| 10 |
+
EMAIL_RE = re.compile(
|
| 11 |
+
r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+"
|
| 12 |
+
)
|
| 13 |
+
PHONE_RE = re.compile(r"\+?\d[\d\s\-()]{7,}\d")
|
| 14 |
+
|
| 15 |
+
BUDGET_HINTS = (
|
| 16 |
+
"budget",
|
| 17 |
+
"€",
|
| 18 |
+
"$",
|
| 19 |
+
"eur",
|
| 20 |
+
"usd",
|
| 21 |
+
"rub",
|
| 22 |
+
"₽",
|
| 23 |
+
"тысяч",
|
| 24 |
+
"euros",
|
| 25 |
+
"dollars",
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
NEED_HINTS = (
|
| 29 |
+
"need",
|
| 30 |
+
"besoin",
|
| 31 |
+
"нужн",
|
| 32 |
+
"want",
|
| 33 |
+
"looking",
|
| 34 |
+
"cherche",
|
| 35 |
+
"ищ",
|
| 36 |
+
"order",
|
| 37 |
+
"commande",
|
| 38 |
+
"заказ",
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
CONTACT_HINTS = (
|
| 42 |
+
"email",
|
| 43 |
+
"mail",
|
| 44 |
+
"phone",
|
| 45 |
+
"téléphone",
|
| 46 |
+
"телефон",
|
| 47 |
+
"whatsapp",
|
| 48 |
+
"call",
|
| 49 |
+
"звон",
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def lead_to_dict(lead: Optional[Lead]) -> dict[str, Any]:
|
| 54 |
+
"""Serialize lead fields for prompts/UI."""
|
| 55 |
+
if lead is None:
|
| 56 |
+
return {
|
| 57 |
+
"name": None,
|
| 58 |
+
"need": None,
|
| 59 |
+
"budget": None,
|
| 60 |
+
"contact_channel": None,
|
| 61 |
+
"email": None,
|
| 62 |
+
"phone": None,
|
| 63 |
+
"complete": False,
|
| 64 |
+
}
|
| 65 |
+
return {
|
| 66 |
+
"name": lead.name,
|
| 67 |
+
"need": lead.need,
|
| 68 |
+
"budget": lead.budget,
|
| 69 |
+
"contact_channel": lead.contact_channel,
|
| 70 |
+
"email": lead.email,
|
| 71 |
+
"phone": lead.phone,
|
| 72 |
+
"complete": lead.complete,
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def update_lead_from_message(
|
| 77 |
+
lead: Lead,
|
| 78 |
+
message: str,
|
| 79 |
+
previous_bot: Optional[str] = None,
|
| 80 |
+
) -> Lead:
|
| 81 |
+
"""Extract lead fields from the latest user message."""
|
| 82 |
+
text = message.strip()
|
| 83 |
+
lowered = text.lower()
|
| 84 |
+
bot = (previous_bot or "").lower()
|
| 85 |
+
|
| 86 |
+
email = EMAIL_RE.search(text)
|
| 87 |
+
if email and not lead.email:
|
| 88 |
+
lead.email = email.group(0)
|
| 89 |
+
if not lead.contact_channel:
|
| 90 |
+
lead.contact_channel = "email"
|
| 91 |
+
|
| 92 |
+
phone = PHONE_RE.search(text)
|
| 93 |
+
if phone and not lead.phone:
|
| 94 |
+
lead.phone = re.sub(r"\s+", "", phone.group(0))
|
| 95 |
+
if not lead.contact_channel:
|
| 96 |
+
lead.contact_channel = "phone"
|
| 97 |
+
|
| 98 |
+
if not lead.name and (
|
| 99 |
+
"name" in bot
|
| 100 |
+
or "nom" in bot
|
| 101 |
+
or "зовут" in bot
|
| 102 |
+
or "зовут" in bot
|
| 103 |
+
or "call me" in lowered
|
| 104 |
+
or "je m'appelle" in lowered
|
| 105 |
+
or "меня зовут" in lowered
|
| 106 |
+
):
|
| 107 |
+
lead.name = _extract_name(text)
|
| 108 |
+
|
| 109 |
+
if not lead.name and _looks_like_name(text):
|
| 110 |
+
lead.name = text.strip()[:80]
|
| 111 |
+
|
| 112 |
+
if not lead.budget and (
|
| 113 |
+
any(h in lowered for h in BUDGET_HINTS)
|
| 114 |
+
or "budget" in bot
|
| 115 |
+
or "бюджет" in bot
|
| 116 |
+
):
|
| 117 |
+
lead.budget = text[:64]
|
| 118 |
+
|
| 119 |
+
if not lead.need and (
|
| 120 |
+
any(h in lowered for h in NEED_HINTS)
|
| 121 |
+
or "need" in bot
|
| 122 |
+
or "besoin" in bot
|
| 123 |
+
or "помощь" in bot
|
| 124 |
+
):
|
| 125 |
+
lead.need = text[:500]
|
| 126 |
+
|
| 127 |
+
if not lead.contact_channel and (
|
| 128 |
+
any(h in lowered for h in CONTACT_HINTS)
|
| 129 |
+
or "reach" in bot
|
| 130 |
+
or "contacter" in bot
|
| 131 |
+
or "связаться" in bot
|
| 132 |
+
):
|
| 133 |
+
if "whatsapp" in lowered:
|
| 134 |
+
lead.contact_channel = "whatsapp"
|
| 135 |
+
elif "mail" in lowered or "email" in lowered:
|
| 136 |
+
lead.contact_channel = "email"
|
| 137 |
+
elif "phone" in lowered or "tél" in lowered or "телефон" in lowered:
|
| 138 |
+
lead.contact_channel = "phone"
|
| 139 |
+
else:
|
| 140 |
+
lead.contact_channel = text[:64]
|
| 141 |
+
|
| 142 |
+
lead.complete = all(
|
| 143 |
+
[
|
| 144 |
+
lead.name,
|
| 145 |
+
lead.need,
|
| 146 |
+
lead.budget,
|
| 147 |
+
lead.contact_channel,
|
| 148 |
+
]
|
| 149 |
+
)
|
| 150 |
+
return lead
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _looks_like_name(text: str) -> bool:
|
| 154 |
+
words = text.strip().split()
|
| 155 |
+
if not (1 <= len(words) <= 3):
|
| 156 |
+
return False
|
| 157 |
+
if any(ch.isdigit() for ch in text):
|
| 158 |
+
return False
|
| 159 |
+
if len(text) > 40:
|
| 160 |
+
return False
|
| 161 |
+
return text[:1].isupper() or all(
|
| 162 |
+
w[:1].isupper() for w in words if w
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _extract_name(text: str) -> str:
|
| 167 |
+
patterns = [
|
| 168 |
+
r"(?:je m'appelle|my name is|i am|i'm|меня зовут)\s+(.+)",
|
| 169 |
+
r"(?:call me)\s+(.+)",
|
| 170 |
+
]
|
| 171 |
+
lowered = text.strip()
|
| 172 |
+
for pattern in patterns:
|
| 173 |
+
match = re.search(pattern, lowered, flags=re.I)
|
| 174 |
+
if match:
|
| 175 |
+
return match.group(1).strip(" .,!")[:80]
|
| 176 |
+
return text.strip()[:80]
|
app/services/llm.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM reply generation with grounded RAG answers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from app.config import get_settings
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def build_system_prompt(
|
| 14 |
+
*,
|
| 15 |
+
business: str,
|
| 16 |
+
context: str,
|
| 17 |
+
lead_state: dict[str, Any],
|
| 18 |
+
) -> str:
|
| 19 |
+
"""Build the system prompt in a summary + context form."""
|
| 20 |
+
system_prompt = (
|
| 21 |
+
f"You are acting as ReplyFlow, the customer assistant "
|
| 22 |
+
f"for {business}. You are answering questions on "
|
| 23 |
+
f"{business}'s website and chat channels, particularly "
|
| 24 |
+
f"questions related to products, pricing, delivery, "
|
| 25 |
+
f"returns, and booking a sales call. "
|
| 26 |
+
f"Your responsibility is to represent {business} as "
|
| 27 |
+
f"faithfully as possible. "
|
| 28 |
+
f"You are given knowledge-base excerpts which you must "
|
| 29 |
+
f"use to answer questions. "
|
| 30 |
+
f"Be professional and engaging, as if talking to a "
|
| 31 |
+
f"potential customer. "
|
| 32 |
+
f"Answer ONLY using the provided Context. Never invent "
|
| 33 |
+
f"prices, policies, or facts. "
|
| 34 |
+
f"If you don't know the answer, say so clearly and offer "
|
| 35 |
+
f"to transfer to a human or book a call. "
|
| 36 |
+
f"If the user seems ready to buy or book, gently offer "
|
| 37 |
+
f"an appointment. "
|
| 38 |
+
f"Reply in English. Be concise and helpful."
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
system_prompt += (
|
| 42 |
+
f"\n\n## Business:\n{business}\n\n"
|
| 43 |
+
f"## Context:\n{context}\n\n"
|
| 44 |
+
f"## Lead profile so far:\n{lead_state}\n\n"
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
system_prompt += (
|
| 48 |
+
"With this context, please chat with the user, always "
|
| 49 |
+
f"staying in character as ReplyFlow for {business}."
|
| 50 |
+
)
|
| 51 |
+
return system_prompt
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
async def generate_reply(
|
| 55 |
+
*,
|
| 56 |
+
question: str,
|
| 57 |
+
language: str,
|
| 58 |
+
context_chunks: list[dict[str, Any]],
|
| 59 |
+
history: list[dict[str, str]],
|
| 60 |
+
lead_state: dict[str, Any],
|
| 61 |
+
) -> tuple[str, bool]:
|
| 62 |
+
"""
|
| 63 |
+
Generate a grounded reply.
|
| 64 |
+
|
| 65 |
+
Returns (reply_text, grounded).
|
| 66 |
+
"""
|
| 67 |
+
settings = get_settings()
|
| 68 |
+
context_text = "\n\n".join(
|
| 69 |
+
f"[{i + 1}] {c['text']}"
|
| 70 |
+
for i, c in enumerate(context_chunks)
|
| 71 |
+
)
|
| 72 |
+
grounded = bool(context_chunks)
|
| 73 |
+
|
| 74 |
+
if not grounded:
|
| 75 |
+
return _no_context_reply(), False
|
| 76 |
+
|
| 77 |
+
if settings.has_openai:
|
| 78 |
+
try:
|
| 79 |
+
return await _openai_reply(
|
| 80 |
+
question=question,
|
| 81 |
+
context_text=context_text,
|
| 82 |
+
history=history,
|
| 83 |
+
lead_state=lead_state,
|
| 84 |
+
), True
|
| 85 |
+
except Exception as exc: # noqa: BLE001
|
| 86 |
+
logger.exception("OpenAI failed: %s", exc)
|
| 87 |
+
|
| 88 |
+
return _demo_reply(
|
| 89 |
+
question=question,
|
| 90 |
+
context_chunks=context_chunks,
|
| 91 |
+
lead_state=lead_state,
|
| 92 |
+
), True
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
async def _openai_reply(
|
| 96 |
+
*,
|
| 97 |
+
question: str,
|
| 98 |
+
context_text: str,
|
| 99 |
+
history: list[dict[str, str]],
|
| 100 |
+
lead_state: dict[str, Any],
|
| 101 |
+
) -> str:
|
| 102 |
+
from openai import AsyncOpenAI
|
| 103 |
+
|
| 104 |
+
settings = get_settings()
|
| 105 |
+
client = AsyncOpenAI(api_key=settings.openai_api_key)
|
| 106 |
+
system = build_system_prompt(
|
| 107 |
+
business=settings.demo_business_name,
|
| 108 |
+
context=context_text,
|
| 109 |
+
lead_state=lead_state,
|
| 110 |
+
)
|
| 111 |
+
messages: list[dict[str, str]] = [
|
| 112 |
+
{"role": "system", "content": system},
|
| 113 |
+
]
|
| 114 |
+
for item in history[-8:]:
|
| 115 |
+
messages.append(
|
| 116 |
+
{"role": item["role"], "content": item["content"]}
|
| 117 |
+
)
|
| 118 |
+
messages.append({"role": "user", "content": question})
|
| 119 |
+
|
| 120 |
+
response = await client.chat.completions.create(
|
| 121 |
+
model=settings.openai_model,
|
| 122 |
+
messages=messages,
|
| 123 |
+
temperature=0.2,
|
| 124 |
+
max_tokens=500,
|
| 125 |
+
)
|
| 126 |
+
return (response.choices[0].message.content or "").strip()
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _no_context_reply() -> str:
|
| 130 |
+
return (
|
| 131 |
+
"I do not have that information in our knowledge "
|
| 132 |
+
"base, so I will not invent an answer. I can "
|
| 133 |
+
"transfer you to a human or help book a call."
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _demo_reply(
|
| 138 |
+
*,
|
| 139 |
+
question: str,
|
| 140 |
+
context_chunks: list[dict[str, Any]],
|
| 141 |
+
lead_state: dict[str, Any],
|
| 142 |
+
) -> str:
|
| 143 |
+
"""Template reply for offline / no-key demo mode."""
|
| 144 |
+
best = context_chunks[0]["text"]
|
| 145 |
+
excerpt = best[:420].rstrip()
|
| 146 |
+
if len(best) > 420:
|
| 147 |
+
excerpt += "…"
|
| 148 |
+
|
| 149 |
+
missing = [
|
| 150 |
+
field
|
| 151 |
+
for field in ("name", "need", "budget", "contact_channel")
|
| 152 |
+
if not lead_state.get(field)
|
| 153 |
+
]
|
| 154 |
+
|
| 155 |
+
followups = {
|
| 156 |
+
"name": "May I have your name?",
|
| 157 |
+
"need": "What do you need help with?",
|
| 158 |
+
"budget": "What budget range are you considering?",
|
| 159 |
+
"contact_channel": (
|
| 160 |
+
"How should we reach you (email or phone)?"
|
| 161 |
+
),
|
| 162 |
+
}
|
| 163 |
+
reply = f"Based on our documentation:\n\n{excerpt}"
|
| 164 |
+
if missing:
|
| 165 |
+
reply += f"\n\n{followups[missing[0]]}"
|
| 166 |
+
else:
|
| 167 |
+
reply += (
|
| 168 |
+
"\n\nI can also propose available meeting slots "
|
| 169 |
+
"if you would like to book a call."
|
| 170 |
+
)
|
| 171 |
+
return reply
|
app/services/rag.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RAG knowledge base: ingest, search, no hallucination."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
import uuid
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from bs4 import BeautifulSoup
|
| 12 |
+
from pypdf import PdfReader
|
| 13 |
+
|
| 14 |
+
from app.config import get_settings
|
| 15 |
+
from app.services.embeddings import (
|
| 16 |
+
chunk_text,
|
| 17 |
+
cosine_similarity,
|
| 18 |
+
embed_texts,
|
| 19 |
+
token_overlap_score,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class KnowledgeStore:
|
| 26 |
+
"""File-backed vector store (Chroma when available)."""
|
| 27 |
+
|
| 28 |
+
def __init__(self) -> None:
|
| 29 |
+
self.settings = get_settings()
|
| 30 |
+
self._collection = None
|
| 31 |
+
self._fallback_path = (
|
| 32 |
+
Path(self.settings.chroma_path) / "fallback.json"
|
| 33 |
+
)
|
| 34 |
+
self._fallback: list[dict[str, Any]] = []
|
| 35 |
+
self._init_store()
|
| 36 |
+
|
| 37 |
+
def _init_store(self) -> None:
|
| 38 |
+
path = Path(self.settings.chroma_path)
|
| 39 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 40 |
+
try:
|
| 41 |
+
import chromadb
|
| 42 |
+
|
| 43 |
+
client = chromadb.PersistentClient(path=str(path))
|
| 44 |
+
self._collection = client.get_or_create_collection(
|
| 45 |
+
name="replyflow_kb",
|
| 46 |
+
metadata={"hnsw:space": "cosine"},
|
| 47 |
+
)
|
| 48 |
+
logger.info("Chroma knowledge store ready")
|
| 49 |
+
except Exception as exc: # noqa: BLE001
|
| 50 |
+
logger.warning(
|
| 51 |
+
"Chroma unavailable (%s); using JSON fallback",
|
| 52 |
+
exc,
|
| 53 |
+
)
|
| 54 |
+
self._collection = None
|
| 55 |
+
self._load_fallback()
|
| 56 |
+
|
| 57 |
+
def _load_fallback(self) -> None:
|
| 58 |
+
if self._fallback_path.exists():
|
| 59 |
+
self._fallback = json.loads(
|
| 60 |
+
self._fallback_path.read_text(encoding="utf-8")
|
| 61 |
+
)
|
| 62 |
+
else:
|
| 63 |
+
self._fallback = []
|
| 64 |
+
|
| 65 |
+
def _save_fallback(self) -> None:
|
| 66 |
+
self._fallback_path.parent.mkdir(
|
| 67 |
+
parents=True, exist_ok=True
|
| 68 |
+
)
|
| 69 |
+
self._fallback_path.write_text(
|
| 70 |
+
json.dumps(self._fallback, ensure_ascii=False),
|
| 71 |
+
encoding="utf-8",
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
@property
|
| 75 |
+
def chunk_count(self) -> int:
|
| 76 |
+
if self._collection is not None:
|
| 77 |
+
return int(self._collection.count())
|
| 78 |
+
return len(self._fallback)
|
| 79 |
+
|
| 80 |
+
async def add_chunks(
|
| 81 |
+
self,
|
| 82 |
+
chunks: list[str],
|
| 83 |
+
metadata: dict[str, Any],
|
| 84 |
+
) -> int:
|
| 85 |
+
"""Embed and store text chunks."""
|
| 86 |
+
if not chunks:
|
| 87 |
+
return 0
|
| 88 |
+
embeddings = await embed_texts(chunks)
|
| 89 |
+
ids = [str(uuid.uuid4()) for _ in chunks]
|
| 90 |
+
metadatas = [
|
| 91 |
+
{**metadata, "chunk_index": i}
|
| 92 |
+
for i in range(len(chunks))
|
| 93 |
+
]
|
| 94 |
+
|
| 95 |
+
if self._collection is not None:
|
| 96 |
+
self._collection.add(
|
| 97 |
+
ids=ids,
|
| 98 |
+
documents=chunks,
|
| 99 |
+
embeddings=embeddings,
|
| 100 |
+
metadatas=metadatas,
|
| 101 |
+
)
|
| 102 |
+
else:
|
| 103 |
+
for i, chunk in enumerate(chunks):
|
| 104 |
+
self._fallback.append(
|
| 105 |
+
{
|
| 106 |
+
"id": ids[i],
|
| 107 |
+
"document": chunk,
|
| 108 |
+
"embedding": embeddings[i],
|
| 109 |
+
"metadata": metadatas[i],
|
| 110 |
+
}
|
| 111 |
+
)
|
| 112 |
+
self._save_fallback()
|
| 113 |
+
return len(chunks)
|
| 114 |
+
|
| 115 |
+
async def search(
|
| 116 |
+
self,
|
| 117 |
+
query: str,
|
| 118 |
+
top_k: int = 4,
|
| 119 |
+
min_score: float = 0.12,
|
| 120 |
+
) -> list[dict[str, Any]]:
|
| 121 |
+
"""Return relevant chunks above similarity threshold."""
|
| 122 |
+
if self.chunk_count == 0:
|
| 123 |
+
return []
|
| 124 |
+
|
| 125 |
+
query_emb = (await embed_texts([query]))[0]
|
| 126 |
+
use_openai = get_settings().has_openai
|
| 127 |
+
|
| 128 |
+
if self._collection is not None:
|
| 129 |
+
result = self._collection.query(
|
| 130 |
+
query_embeddings=[query_emb],
|
| 131 |
+
n_results=min(top_k, max(1, self.chunk_count)),
|
| 132 |
+
)
|
| 133 |
+
docs = result.get("documents", [[]])[0]
|
| 134 |
+
metas = result.get("metadatas", [[]])[0]
|
| 135 |
+
dists = result.get("distances", [[]])[0]
|
| 136 |
+
hits: list[dict[str, Any]] = []
|
| 137 |
+
for doc, meta, dist in zip(docs, metas, dists):
|
| 138 |
+
vector_score = 1.0 - float(dist)
|
| 139 |
+
keyword = token_overlap_score(query, doc)
|
| 140 |
+
score = (
|
| 141 |
+
vector_score
|
| 142 |
+
if use_openai
|
| 143 |
+
else 0.45 * vector_score + 0.55 * keyword
|
| 144 |
+
)
|
| 145 |
+
if score >= min_score:
|
| 146 |
+
hits.append(
|
| 147 |
+
{
|
| 148 |
+
"text": doc,
|
| 149 |
+
"metadata": meta or {},
|
| 150 |
+
"score": score,
|
| 151 |
+
}
|
| 152 |
+
)
|
| 153 |
+
hits.sort(key=lambda x: x["score"], reverse=True)
|
| 154 |
+
return hits[:top_k]
|
| 155 |
+
|
| 156 |
+
scored = []
|
| 157 |
+
for item in self._fallback:
|
| 158 |
+
vector_score = cosine_similarity(
|
| 159 |
+
query_emb, item["embedding"]
|
| 160 |
+
)
|
| 161 |
+
keyword = token_overlap_score(query, item["document"])
|
| 162 |
+
score = 0.35 * vector_score + 0.65 * keyword
|
| 163 |
+
if score >= min_score:
|
| 164 |
+
scored.append(
|
| 165 |
+
{
|
| 166 |
+
"text": item["document"],
|
| 167 |
+
"metadata": item["metadata"],
|
| 168 |
+
"score": score,
|
| 169 |
+
}
|
| 170 |
+
)
|
| 171 |
+
scored.sort(key=lambda x: x["score"], reverse=True)
|
| 172 |
+
return scored[:top_k]
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
_store: KnowledgeStore | None = None
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def get_knowledge_store() -> KnowledgeStore:
|
| 179 |
+
"""Singleton knowledge store."""
|
| 180 |
+
global _store
|
| 181 |
+
if _store is None:
|
| 182 |
+
_store = KnowledgeStore()
|
| 183 |
+
return _store
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def extract_text_from_file(path: Path) -> str:
|
| 187 |
+
"""Extract text from pdf, html, md, or txt."""
|
| 188 |
+
suffix = path.suffix.lower()
|
| 189 |
+
raw = path.read_bytes()
|
| 190 |
+
if suffix == ".pdf":
|
| 191 |
+
reader = PdfReader(str(path))
|
| 192 |
+
parts = []
|
| 193 |
+
for page in reader.pages:
|
| 194 |
+
parts.append(page.extract_text() or "")
|
| 195 |
+
return "\n".join(parts)
|
| 196 |
+
text = raw.decode("utf-8", errors="ignore")
|
| 197 |
+
if suffix in {".html", ".htm"}:
|
| 198 |
+
soup = BeautifulSoup(text, "html.parser")
|
| 199 |
+
return soup.get_text(separator="\n")
|
| 200 |
+
return text
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
async def ingest_path(path: Path, source_type: str) -> int:
|
| 204 |
+
"""Ingest a single file into the knowledge store."""
|
| 205 |
+
text = extract_text_from_file(path)
|
| 206 |
+
chunks = chunk_text(text)
|
| 207 |
+
store = get_knowledge_store()
|
| 208 |
+
return await store.add_chunks(
|
| 209 |
+
chunks,
|
| 210 |
+
{
|
| 211 |
+
"filename": path.name,
|
| 212 |
+
"source_type": source_type,
|
| 213 |
+
},
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
async def ingest_directory(directory: Path) -> int:
|
| 218 |
+
"""Ingest all supported files from a directory."""
|
| 219 |
+
total = 0
|
| 220 |
+
patterns = ("*.md", "*.txt", "*.pdf", "*.html", "*.htm")
|
| 221 |
+
files: list[Path] = []
|
| 222 |
+
for pattern in patterns:
|
| 223 |
+
files.extend(directory.glob(pattern))
|
| 224 |
+
for path in sorted(set(files)):
|
| 225 |
+
source = path.suffix.lower().lstrip(".") or "text"
|
| 226 |
+
count = await ingest_path(path, source)
|
| 227 |
+
total += count
|
| 228 |
+
logger.info("Ingested %s (%s chunks)", path.name, count)
|
| 229 |
+
return total
|
app/static/chat.html
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>ReplyFlow Chat</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Syne:wght@600;700;800&display=swap" rel="stylesheet" />
|
| 10 |
+
<link rel="stylesheet" href="/static/css/styles.css" />
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<div class="shell">
|
| 14 |
+
<header class="topbar">
|
| 15 |
+
<a class="brand" href="/">
|
| 16 |
+
<span class="brand-mark" aria-hidden="true"></span>
|
| 17 |
+
<span>ReplyFlow</span>
|
| 18 |
+
</a>
|
| 19 |
+
<nav class="nav">
|
| 20 |
+
<a href="/dashboard">Dashboard</a>
|
| 21 |
+
</nav>
|
| 22 |
+
</header>
|
| 23 |
+
|
| 24 |
+
<div class="chat-layout">
|
| 25 |
+
<section class="panel chat-panel">
|
| 26 |
+
<div class="chat-header">
|
| 27 |
+
<div>
|
| 28 |
+
<h1>ReplyFlow assistant</h1>
|
| 29 |
+
<div style="color:var(--muted);font-size:0.9rem">
|
| 30 |
+
Ask about products, pricing, delivery, or booking
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
<span class="badge" id="conv-id">—</span>
|
| 34 |
+
</div>
|
| 35 |
+
<div class="messages" id="messages" aria-live="polite"></div>
|
| 36 |
+
<form class="composer" id="composer">
|
| 37 |
+
<input
|
| 38 |
+
id="message-input"
|
| 39 |
+
type="text"
|
| 40 |
+
autocomplete="off"
|
| 41 |
+
placeholder="Type your message…"
|
| 42 |
+
required
|
| 43 |
+
/>
|
| 44 |
+
<button class="btn btn-primary" type="submit">Send</button>
|
| 45 |
+
</form>
|
| 46 |
+
</section>
|
| 47 |
+
|
| 48 |
+
<aside class="side-stack">
|
| 49 |
+
<div class="panel side-card">
|
| 50 |
+
<h2>Lead profile</h2>
|
| 51 |
+
<div class="kv" id="lead-box">
|
| 52 |
+
<div><span>Name</span><span id="lead-name">—</span></div>
|
| 53 |
+
<div><span>Need</span><span id="lead-need">—</span></div>
|
| 54 |
+
<div><span>Budget</span><span id="lead-budget">—</span></div>
|
| 55 |
+
<div><span>Contact</span><span id="lead-contact">—</span></div>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
<div class="panel side-card">
|
| 59 |
+
<h2>Suggested slots</h2>
|
| 60 |
+
<div class="slots" id="slots-box">
|
| 61 |
+
<div style="color:var(--muted);font-size:0.9rem">—</div>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
<div class="panel side-card">
|
| 65 |
+
<h2>Integrations</h2>
|
| 66 |
+
<div class="kv" id="health-box">
|
| 67 |
+
<div><span>OpenAI</span><span id="h-openai">…</span></div>
|
| 68 |
+
<div><span>Cal.com</span><span id="h-calcom">…</span></div>
|
| 69 |
+
<div><span>WhatsApp</span><span id="h-whatsapp">…</span></div>
|
| 70 |
+
<div><span>KB chunks</span><span id="h-chunks">…</span></div>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
</aside>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<script src="/static/js/chat.js"></script>
|
| 78 |
+
</body>
|
| 79 |
+
</html>
|
app/static/css/styles.css
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ReplyFlow design system */
|
| 2 |
+
:root {
|
| 3 |
+
--bg0: #0f1c1a;
|
| 4 |
+
--bg1: #16302b;
|
| 5 |
+
--bg2: #1e3d36;
|
| 6 |
+
--ink: #f3f0e8;
|
| 7 |
+
--muted: #b7c4bf;
|
| 8 |
+
--line: rgba(243, 240, 232, 0.12);
|
| 9 |
+
--accent: #d4a24c;
|
| 10 |
+
--accent-2: #7ec8b4;
|
| 11 |
+
--danger: #e07a5f;
|
| 12 |
+
--ok: #7ec8b4;
|
| 13 |
+
--card: rgba(22, 48, 43, 0.72);
|
| 14 |
+
--shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
|
| 15 |
+
--radius: 18px;
|
| 16 |
+
--font-display: "Syne", sans-serif;
|
| 17 |
+
--font-body: "Manrope", sans-serif;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
* {
|
| 21 |
+
box-sizing: border-box;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
html,
|
| 25 |
+
body {
|
| 26 |
+
margin: 0;
|
| 27 |
+
padding: 0;
|
| 28 |
+
min-height: 100%;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
body {
|
| 32 |
+
font-family: var(--font-body);
|
| 33 |
+
color: var(--ink);
|
| 34 |
+
background:
|
| 35 |
+
radial-gradient(1200px 700px at 10% -10%, #2a5a4e 0%, transparent 55%),
|
| 36 |
+
radial-gradient(900px 600px at 100% 0%, #3a2f18 0%, transparent 50%),
|
| 37 |
+
linear-gradient(160deg, var(--bg0), #122421 45%, #0c1614);
|
| 38 |
+
background-attachment: fixed;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
a {
|
| 42 |
+
color: var(--accent-2);
|
| 43 |
+
text-decoration: none;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
a:hover {
|
| 47 |
+
color: var(--accent);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.shell {
|
| 51 |
+
width: min(1120px, calc(100% - 2rem));
|
| 52 |
+
margin: 0 auto;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.topbar {
|
| 56 |
+
display: flex;
|
| 57 |
+
align-items: center;
|
| 58 |
+
justify-content: space-between;
|
| 59 |
+
gap: 1rem;
|
| 60 |
+
padding: 1.25rem 0;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.brand {
|
| 64 |
+
display: flex;
|
| 65 |
+
align-items: center;
|
| 66 |
+
gap: 0.75rem;
|
| 67 |
+
font-family: var(--font-display);
|
| 68 |
+
font-weight: 700;
|
| 69 |
+
letter-spacing: -0.03em;
|
| 70 |
+
font-size: 1.35rem;
|
| 71 |
+
color: var(--ink);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.brand-mark {
|
| 75 |
+
width: 2rem;
|
| 76 |
+
height: 2rem;
|
| 77 |
+
border-radius: 0.55rem;
|
| 78 |
+
background:
|
| 79 |
+
linear-gradient(135deg, var(--accent), #f0d29a),
|
| 80 |
+
var(--accent);
|
| 81 |
+
box-shadow: 0 0 0 4px rgba(212, 162, 76, 0.15);
|
| 82 |
+
animation: pulse-mark 3.2s ease-in-out infinite;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
@keyframes pulse-mark {
|
| 86 |
+
0%,
|
| 87 |
+
100% {
|
| 88 |
+
transform: scale(1);
|
| 89 |
+
}
|
| 90 |
+
50% {
|
| 91 |
+
transform: scale(1.06);
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.nav {
|
| 96 |
+
display: flex;
|
| 97 |
+
align-items: center;
|
| 98 |
+
gap: 0.85rem;
|
| 99 |
+
flex-wrap: wrap;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.nav a {
|
| 103 |
+
color: var(--muted);
|
| 104 |
+
font-size: 0.95rem;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.hero {
|
| 108 |
+
min-height: calc(100vh - 5.5rem);
|
| 109 |
+
display: grid;
|
| 110 |
+
align-items: end;
|
| 111 |
+
padding: 2rem 0 4rem;
|
| 112 |
+
position: relative;
|
| 113 |
+
overflow: hidden;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.hero-copy {
|
| 117 |
+
max-width: 38rem;
|
| 118 |
+
position: relative;
|
| 119 |
+
z-index: 2;
|
| 120 |
+
animation: rise 0.9s ease both;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
@keyframes rise {
|
| 124 |
+
from {
|
| 125 |
+
opacity: 0;
|
| 126 |
+
transform: translateY(18px);
|
| 127 |
+
}
|
| 128 |
+
to {
|
| 129 |
+
opacity: 1;
|
| 130 |
+
transform: translateY(0);
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.hero h1 {
|
| 135 |
+
font-family: var(--font-display);
|
| 136 |
+
font-size: clamp(2.8rem, 7vw, 5.2rem);
|
| 137 |
+
line-height: 0.95;
|
| 138 |
+
letter-spacing: -0.045em;
|
| 139 |
+
margin: 0 0 1rem;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.hero p {
|
| 143 |
+
color: var(--muted);
|
| 144 |
+
font-size: 1.1rem;
|
| 145 |
+
line-height: 1.55;
|
| 146 |
+
margin: 0 0 1.6rem;
|
| 147 |
+
max-width: 32rem;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.cta-row {
|
| 151 |
+
display: flex;
|
| 152 |
+
gap: 0.75rem;
|
| 153 |
+
flex-wrap: wrap;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.btn {
|
| 157 |
+
display: inline-flex;
|
| 158 |
+
align-items: center;
|
| 159 |
+
justify-content: center;
|
| 160 |
+
gap: 0.4rem;
|
| 161 |
+
border: 0;
|
| 162 |
+
border-radius: 999px;
|
| 163 |
+
padding: 0.85rem 1.25rem;
|
| 164 |
+
font: inherit;
|
| 165 |
+
font-weight: 600;
|
| 166 |
+
cursor: pointer;
|
| 167 |
+
transition: transform 0.2s ease, background 0.2s ease;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.btn:hover {
|
| 171 |
+
transform: translateY(-1px);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.btn-primary {
|
| 175 |
+
background: linear-gradient(135deg, var(--accent), #e8c078);
|
| 176 |
+
color: #1a1408;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.btn-ghost {
|
| 180 |
+
background: transparent;
|
| 181 |
+
color: var(--ink);
|
| 182 |
+
border: 1px solid var(--line);
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.hero-visual {
|
| 186 |
+
position: absolute;
|
| 187 |
+
inset: 8% 0 auto auto;
|
| 188 |
+
width: min(48vw, 520px);
|
| 189 |
+
height: min(62vh, 560px);
|
| 190 |
+
border-radius: 28% 48% 36% 54% / 42% 30% 55% 40%;
|
| 191 |
+
background:
|
| 192 |
+
linear-gradient(145deg, rgba(126, 200, 180, 0.35), transparent 55%),
|
| 193 |
+
linear-gradient(320deg, rgba(212, 162, 76, 0.4), transparent 50%),
|
| 194 |
+
#214940;
|
| 195 |
+
filter: blur(0.2px);
|
| 196 |
+
animation: morph 12s ease-in-out infinite;
|
| 197 |
+
z-index: 1;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
@keyframes morph {
|
| 201 |
+
0%,
|
| 202 |
+
100% {
|
| 203 |
+
border-radius: 28% 48% 36% 54% / 42% 30% 55% 40%;
|
| 204 |
+
transform: translateY(0) rotate(0deg);
|
| 205 |
+
}
|
| 206 |
+
50% {
|
| 207 |
+
border-radius: 48% 28% 54% 36% / 30% 48% 36% 52%;
|
| 208 |
+
transform: translateY(12px) rotate(4deg);
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
.section {
|
| 213 |
+
padding: 3.5rem 0;
|
| 214 |
+
border-top: 1px solid var(--line);
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
.section h2 {
|
| 218 |
+
font-family: var(--font-display);
|
| 219 |
+
font-size: clamp(1.8rem, 3vw, 2.4rem);
|
| 220 |
+
letter-spacing: -0.03em;
|
| 221 |
+
margin: 0 0 0.6rem;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.section > p {
|
| 225 |
+
color: var(--muted);
|
| 226 |
+
margin: 0 0 1.5rem;
|
| 227 |
+
max-width: 40rem;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.feature-list {
|
| 231 |
+
display: grid;
|
| 232 |
+
gap: 1rem;
|
| 233 |
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.feature {
|
| 237 |
+
padding: 1.1rem 0;
|
| 238 |
+
border-top: 1px solid var(--line);
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.feature h3 {
|
| 242 |
+
margin: 0 0 0.35rem;
|
| 243 |
+
font-size: 1.05rem;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.feature p {
|
| 247 |
+
margin: 0;
|
| 248 |
+
color: var(--muted);
|
| 249 |
+
line-height: 1.5;
|
| 250 |
+
font-size: 0.95rem;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.panel {
|
| 254 |
+
background: var(--card);
|
| 255 |
+
border: 1px solid var(--line);
|
| 256 |
+
border-radius: var(--radius);
|
| 257 |
+
box-shadow: var(--shadow);
|
| 258 |
+
backdrop-filter: blur(10px);
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
.chat-layout {
|
| 262 |
+
display: grid;
|
| 263 |
+
grid-template-columns: 1fr;
|
| 264 |
+
gap: 1rem;
|
| 265 |
+
padding: 1rem 0 2rem;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
@media (min-width: 900px) {
|
| 269 |
+
.chat-layout {
|
| 270 |
+
grid-template-columns: 1.5fr 0.9fr;
|
| 271 |
+
align-items: start;
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
.chat-panel {
|
| 276 |
+
display: flex;
|
| 277 |
+
flex-direction: column;
|
| 278 |
+
min-height: 70vh;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
.chat-header {
|
| 282 |
+
padding: 1rem 1.1rem;
|
| 283 |
+
border-bottom: 1px solid var(--line);
|
| 284 |
+
display: flex;
|
| 285 |
+
justify-content: space-between;
|
| 286 |
+
gap: 1rem;
|
| 287 |
+
align-items: center;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.chat-header h1 {
|
| 291 |
+
margin: 0;
|
| 292 |
+
font-family: var(--font-display);
|
| 293 |
+
font-size: 1.35rem;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.messages {
|
| 297 |
+
flex: 1;
|
| 298 |
+
overflow-y: auto;
|
| 299 |
+
padding: 1rem;
|
| 300 |
+
display: flex;
|
| 301 |
+
flex-direction: column;
|
| 302 |
+
gap: 0.75rem;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.bubble {
|
| 306 |
+
max-width: 85%;
|
| 307 |
+
padding: 0.85rem 1rem;
|
| 308 |
+
border-radius: 1rem;
|
| 309 |
+
line-height: 1.45;
|
| 310 |
+
white-space: pre-wrap;
|
| 311 |
+
animation: rise 0.35s ease both;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
.bubble.user {
|
| 315 |
+
align-self: flex-end;
|
| 316 |
+
background: rgba(212, 162, 76, 0.22);
|
| 317 |
+
border: 1px solid rgba(212, 162, 76, 0.35);
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.bubble.assistant {
|
| 321 |
+
align-self: flex-start;
|
| 322 |
+
background: rgba(255, 255, 255, 0.05);
|
| 323 |
+
border: 1px solid var(--line);
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
.composer {
|
| 327 |
+
display: flex;
|
| 328 |
+
gap: 0.6rem;
|
| 329 |
+
padding: 0.9rem;
|
| 330 |
+
border-top: 1px solid var(--line);
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
.composer input {
|
| 334 |
+
flex: 1;
|
| 335 |
+
border: 1px solid var(--line);
|
| 336 |
+
background: rgba(0, 0, 0, 0.25);
|
| 337 |
+
color: var(--ink);
|
| 338 |
+
border-radius: 999px;
|
| 339 |
+
padding: 0.85rem 1rem;
|
| 340 |
+
font: inherit;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.composer input:focus {
|
| 344 |
+
outline: 1px solid rgba(212, 162, 76, 0.55);
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
.side-stack {
|
| 348 |
+
display: grid;
|
| 349 |
+
gap: 1rem;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.side-card {
|
| 353 |
+
padding: 1rem 1.1rem;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
.side-card h2 {
|
| 357 |
+
margin: 0 0 0.75rem;
|
| 358 |
+
font-size: 1rem;
|
| 359 |
+
font-family: var(--font-display);
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
.kv {
|
| 363 |
+
display: grid;
|
| 364 |
+
gap: 0.45rem;
|
| 365 |
+
font-size: 0.92rem;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
.kv div {
|
| 369 |
+
display: flex;
|
| 370 |
+
justify-content: space-between;
|
| 371 |
+
gap: 1rem;
|
| 372 |
+
border-bottom: 1px dashed var(--line);
|
| 373 |
+
padding-bottom: 0.35rem;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
.kv span:last-child {
|
| 377 |
+
color: var(--muted);
|
| 378 |
+
text-align: right;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
.slots {
|
| 382 |
+
display: grid;
|
| 383 |
+
gap: 0.5rem;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
.slot-btn {
|
| 387 |
+
text-align: left;
|
| 388 |
+
border: 1px solid var(--line);
|
| 389 |
+
background: rgba(0, 0, 0, 0.2);
|
| 390 |
+
color: var(--ink);
|
| 391 |
+
border-radius: 12px;
|
| 392 |
+
padding: 0.7rem 0.8rem;
|
| 393 |
+
cursor: pointer;
|
| 394 |
+
font: inherit;
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
.slot-btn:hover {
|
| 398 |
+
border-color: rgba(126, 200, 180, 0.6);
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
.stats-grid {
|
| 402 |
+
display: grid;
|
| 403 |
+
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
| 404 |
+
gap: 0.85rem;
|
| 405 |
+
margin: 1rem 0 1.5rem;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.stat {
|
| 409 |
+
padding: 1rem;
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
.stat .label {
|
| 413 |
+
color: var(--muted);
|
| 414 |
+
font-size: 0.85rem;
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
.stat .value {
|
| 418 |
+
font-family: var(--font-display);
|
| 419 |
+
font-size: 1.8rem;
|
| 420 |
+
margin-top: 0.25rem;
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
.table-wrap {
|
| 424 |
+
overflow-x: auto;
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
table {
|
| 428 |
+
width: 100%;
|
| 429 |
+
border-collapse: collapse;
|
| 430 |
+
font-size: 0.92rem;
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
th,
|
| 434 |
+
td {
|
| 435 |
+
text-align: left;
|
| 436 |
+
padding: 0.7rem 0.55rem;
|
| 437 |
+
border-bottom: 1px solid var(--line);
|
| 438 |
+
vertical-align: top;
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
th {
|
| 442 |
+
color: var(--muted);
|
| 443 |
+
font-weight: 600;
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
.badge {
|
| 447 |
+
display: inline-block;
|
| 448 |
+
padding: 0.15rem 0.5rem;
|
| 449 |
+
border-radius: 999px;
|
| 450 |
+
border: 1px solid var(--line);
|
| 451 |
+
font-size: 0.75rem;
|
| 452 |
+
color: var(--muted);
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
.badge.ok {
|
| 456 |
+
color: var(--ok);
|
| 457 |
+
border-color: rgba(126, 200, 180, 0.4);
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
.badge.warn {
|
| 461 |
+
color: var(--danger);
|
| 462 |
+
border-color: rgba(224, 122, 95, 0.45);
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
.footer {
|
| 466 |
+
padding: 2rem 0 3rem;
|
| 467 |
+
color: var(--muted);
|
| 468 |
+
font-size: 0.9rem;
|
| 469 |
+
border-top: 1px solid var(--line);
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
@media (max-width: 720px) {
|
| 473 |
+
.hero-visual {
|
| 474 |
+
opacity: 0.35;
|
| 475 |
+
width: 70vw;
|
| 476 |
+
height: 40vh;
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
.topbar {
|
| 480 |
+
flex-direction: column;
|
| 481 |
+
align-items: flex-start;
|
| 482 |
+
}
|
| 483 |
+
}
|
app/static/dashboard.html
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>ReplyFlow Dashboard</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Syne:wght@600;700;800&display=swap" rel="stylesheet" />
|
| 10 |
+
<link rel="stylesheet" href="/static/css/styles.css" />
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<div class="shell">
|
| 14 |
+
<header class="topbar">
|
| 15 |
+
<a class="brand" href="/">
|
| 16 |
+
<span class="brand-mark" aria-hidden="true"></span>
|
| 17 |
+
<span>ReplyFlow</span>
|
| 18 |
+
</a>
|
| 19 |
+
<nav class="nav">
|
| 20 |
+
<a href="/chat">Live demo</a>
|
| 21 |
+
</nav>
|
| 22 |
+
</header>
|
| 23 |
+
|
| 24 |
+
<section style="padding:1rem 0 2.5rem">
|
| 25 |
+
<h1 style="font-family:var(--font-display);letter-spacing:-0.03em;margin:0 0 0.4rem">
|
| 26 |
+
Operator dashboard
|
| 27 |
+
</h1>
|
| 28 |
+
<p style="color:var(--muted);margin:0 0 1rem">
|
| 29 |
+
Conversation metrics and unanswered questions.
|
| 30 |
+
</p>
|
| 31 |
+
|
| 32 |
+
<div class="stats-grid" id="stats-grid">
|
| 33 |
+
<div class="panel stat"><div class="label">Conversations</div><div class="value" id="s-total">0</div></div>
|
| 34 |
+
<div class="panel stat"><div class="label">Resolved without human</div><div class="value" id="s-rate">0%</div></div>
|
| 35 |
+
<div class="panel stat"><div class="label">Appointments</div><div class="value" id="s-appt">0</div></div>
|
| 36 |
+
<div class="panel stat"><div class="label">Handed off</div><div class="value" id="s-hand">0</div></div>
|
| 37 |
+
<div class="panel stat"><div class="label">Unanswered</div><div class="value" id="s-unans">0</div></div>
|
| 38 |
+
<div class="panel stat"><div class="label">Complete leads</div><div class="value" id="s-leads">0</div></div>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<div class="panel" style="padding:1rem;margin-bottom:1rem">
|
| 42 |
+
<h2 style="font-family:var(--font-display);font-size:1.1rem;margin:0 0 0.8rem">
|
| 43 |
+
Recent conversations
|
| 44 |
+
</h2>
|
| 45 |
+
<div class="table-wrap">
|
| 46 |
+
<table>
|
| 47 |
+
<thead>
|
| 48 |
+
<tr>
|
| 49 |
+
<th>ID</th>
|
| 50 |
+
<th>Channel</th>
|
| 51 |
+
<th>Lang</th>
|
| 52 |
+
<th>Lead</th>
|
| 53 |
+
<th>Status</th>
|
| 54 |
+
<th>Msgs</th>
|
| 55 |
+
<th>When</th>
|
| 56 |
+
<th></th>
|
| 57 |
+
</tr>
|
| 58 |
+
</thead>
|
| 59 |
+
<tbody id="conv-body"></tbody>
|
| 60 |
+
</table>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<div class="panel" style="padding:1rem">
|
| 65 |
+
<h2 style="font-family:var(--font-display);font-size:1.1rem;margin:0 0 0.8rem">
|
| 66 |
+
Recent unanswered
|
| 67 |
+
</h2>
|
| 68 |
+
<div class="table-wrap">
|
| 69 |
+
<table>
|
| 70 |
+
<thead>
|
| 71 |
+
<tr>
|
| 72 |
+
<th>ID</th>
|
| 73 |
+
<th>Conversation</th>
|
| 74 |
+
<th>Question</th>
|
| 75 |
+
<th>When</th>
|
| 76 |
+
</tr>
|
| 77 |
+
</thead>
|
| 78 |
+
<tbody id="unans-body"></tbody>
|
| 79 |
+
</table>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
</section>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
<script src="/static/js/dashboard.js"></script>
|
| 86 |
+
</body>
|
| 87 |
+
</html>
|
app/static/index.html
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>ReplyFlow</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Syne:wght@600;700;800&display=swap" rel="stylesheet" />
|
| 10 |
+
<link rel="stylesheet" href="/static/css/styles.css" />
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<div class="shell">
|
| 14 |
+
<header class="topbar">
|
| 15 |
+
<a class="brand" href="/">
|
| 16 |
+
<span class="brand-mark" aria-hidden="true"></span>
|
| 17 |
+
<span>ReplyFlow</span>
|
| 18 |
+
</a>
|
| 19 |
+
<nav class="nav">
|
| 20 |
+
<a href="/chat">Live demo</a>
|
| 21 |
+
<a href="/dashboard">Dashboard</a>
|
| 22 |
+
</nav>
|
| 23 |
+
</header>
|
| 24 |
+
|
| 25 |
+
<section class="hero">
|
| 26 |
+
<div class="hero-visual" aria-hidden="true"></div>
|
| 27 |
+
<div class="hero-copy">
|
| 28 |
+
<h1>ReplyFlow</h1>
|
| 29 |
+
<p>
|
| 30 |
+
Web and WhatsApp chatbot for SMEs — with booking,
|
| 31 |
+
lead capture, and human handoff.
|
| 32 |
+
</p>
|
| 33 |
+
<div class="cta-row">
|
| 34 |
+
<a class="btn btn-primary" href="/chat">Open chat demo</a>
|
| 35 |
+
<a class="btn btn-ghost" href="/dashboard">View dashboard</a>
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
</section>
|
| 39 |
+
|
| 40 |
+
<section class="section">
|
| 41 |
+
<h2>What it does</h2>
|
| 42 |
+
<p>Built for portfolio demos and real SME deployments.</p>
|
| 43 |
+
<div class="feature-list">
|
| 44 |
+
<article class="feature">
|
| 45 |
+
<h3>Grounded RAG answers</h3>
|
| 46 |
+
<p>Replies only from your docs. If the answer is missing, it says so.</p>
|
| 47 |
+
</article>
|
| 48 |
+
<article class="feature">
|
| 49 |
+
<h3>Lead qualification</h3>
|
| 50 |
+
<p>Collects name, need, budget, and contact channel in conversation.</p>
|
| 51 |
+
</article>
|
| 52 |
+
<article class="feature">
|
| 53 |
+
<h3>Appointments 24/7</h3>
|
| 54 |
+
<p>Suggests three slots and books via Cal.com when keys are set.</p>
|
| 55 |
+
</article>
|
| 56 |
+
<article class="feature">
|
| 57 |
+
<h3>Human handoff</h3>
|
| 58 |
+
<p>Escalates after failures, explicit requests, or sensitive topics.</p>
|
| 59 |
+
</article>
|
| 60 |
+
</div>
|
| 61 |
+
</section>
|
| 62 |
+
|
| 63 |
+
<footer class="footer">
|
| 64 |
+
ReplyFlow · FastAPI · Docker
|
| 65 |
+
</footer>
|
| 66 |
+
</div>
|
| 67 |
+
<script src="/widget.js" defer></script>
|
| 68 |
+
</body>
|
| 69 |
+
</html>
|
app/static/js/chat.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(() => {
|
| 2 |
+
const messagesEl = document.getElementById("messages");
|
| 3 |
+
const composer = document.getElementById("composer");
|
| 4 |
+
const input = document.getElementById("message-input");
|
| 5 |
+
const convIdEl = document.getElementById("conv-id");
|
| 6 |
+
const slotsBox = document.getElementById("slots-box");
|
| 7 |
+
|
| 8 |
+
let conversationId = null;
|
| 9 |
+
let busy = false;
|
| 10 |
+
|
| 11 |
+
function addBubble(role, text) {
|
| 12 |
+
const div = document.createElement("div");
|
| 13 |
+
div.className = `bubble ${role}`;
|
| 14 |
+
div.textContent = text;
|
| 15 |
+
messagesEl.appendChild(div);
|
| 16 |
+
messagesEl.scrollTop = messagesEl.scrollHeight;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function renderLead(lead) {
|
| 20 |
+
document.getElementById("lead-name").textContent =
|
| 21 |
+
(lead && lead.name) || "—";
|
| 22 |
+
document.getElementById("lead-need").textContent =
|
| 23 |
+
(lead && lead.need) || "—";
|
| 24 |
+
document.getElementById("lead-budget").textContent =
|
| 25 |
+
(lead && lead.budget) || "—";
|
| 26 |
+
document.getElementById("lead-contact").textContent =
|
| 27 |
+
(lead && (lead.contact_channel || lead.email || lead.phone)) ||
|
| 28 |
+
"—";
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function renderSlots(slots) {
|
| 32 |
+
slotsBox.innerHTML = "";
|
| 33 |
+
if (!slots || !slots.length) {
|
| 34 |
+
slotsBox.innerHTML =
|
| 35 |
+
'<div style="color:var(--muted);font-size:0.9rem">—</div>';
|
| 36 |
+
return;
|
| 37 |
+
}
|
| 38 |
+
slots.forEach((slot) => {
|
| 39 |
+
const btn = document.createElement("button");
|
| 40 |
+
btn.type = "button";
|
| 41 |
+
btn.className = "slot-btn";
|
| 42 |
+
btn.textContent = slot.label || slot.start;
|
| 43 |
+
btn.addEventListener("click", () => bookSlot(slot));
|
| 44 |
+
slotsBox.appendChild(btn);
|
| 45 |
+
});
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
async function bookSlot(slot) {
|
| 49 |
+
if (!conversationId) return;
|
| 50 |
+
const res = await fetch("/api/appointments/book", {
|
| 51 |
+
method: "POST",
|
| 52 |
+
headers: { "Content-Type": "application/json" },
|
| 53 |
+
body: JSON.stringify({
|
| 54 |
+
conversation_id: conversationId,
|
| 55 |
+
slot_start: slot.start,
|
| 56 |
+
slot_end: slot.end,
|
| 57 |
+
}),
|
| 58 |
+
});
|
| 59 |
+
const data = await res.json();
|
| 60 |
+
if (!res.ok) {
|
| 61 |
+
addBubble("assistant", data.detail || "Booking failed");
|
| 62 |
+
return;
|
| 63 |
+
}
|
| 64 |
+
addBubble(
|
| 65 |
+
"assistant",
|
| 66 |
+
data.message || "Appointment booked"
|
| 67 |
+
);
|
| 68 |
+
renderSlots([]);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
async function loadHealth() {
|
| 72 |
+
try {
|
| 73 |
+
const res = await fetch("/api/health");
|
| 74 |
+
const data = await res.json();
|
| 75 |
+
document.getElementById("h-openai").textContent = data.openai
|
| 76 |
+
? "on"
|
| 77 |
+
: "demo";
|
| 78 |
+
document.getElementById("h-calcom").textContent = data.calcom
|
| 79 |
+
? "on"
|
| 80 |
+
: "demo";
|
| 81 |
+
document.getElementById("h-whatsapp").textContent = data.whatsapp
|
| 82 |
+
? "on"
|
| 83 |
+
: "demo";
|
| 84 |
+
document.getElementById("h-chunks").textContent =
|
| 85 |
+
data.knowledge_chunks;
|
| 86 |
+
} catch (_) {
|
| 87 |
+
/* ignore */
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
async function startChat() {
|
| 92 |
+
messagesEl.innerHTML = "";
|
| 93 |
+
const res = await fetch(
|
| 94 |
+
"/api/chat/start?ui_language=en",
|
| 95 |
+
{ method: "POST" }
|
| 96 |
+
);
|
| 97 |
+
const data = await res.json();
|
| 98 |
+
conversationId = data.conversation_id;
|
| 99 |
+
convIdEl.textContent = `#${conversationId}`;
|
| 100 |
+
addBubble("assistant", data.greeting);
|
| 101 |
+
renderLead(null);
|
| 102 |
+
renderSlots([]);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
composer.addEventListener("submit", async (event) => {
|
| 106 |
+
event.preventDefault();
|
| 107 |
+
if (busy || !conversationId) return;
|
| 108 |
+
const text = input.value.trim();
|
| 109 |
+
if (!text) return;
|
| 110 |
+
input.value = "";
|
| 111 |
+
addBubble("user", text);
|
| 112 |
+
busy = true;
|
| 113 |
+
try {
|
| 114 |
+
const res = await fetch("/api/chat/message", {
|
| 115 |
+
method: "POST",
|
| 116 |
+
headers: { "Content-Type": "application/json" },
|
| 117 |
+
body: JSON.stringify({
|
| 118 |
+
conversation_id: conversationId,
|
| 119 |
+
message: text,
|
| 120 |
+
ui_language: "en",
|
| 121 |
+
}),
|
| 122 |
+
});
|
| 123 |
+
const data = await res.json();
|
| 124 |
+
addBubble("assistant", data.reply);
|
| 125 |
+
renderLead(data.lead);
|
| 126 |
+
renderSlots(data.suggested_slots);
|
| 127 |
+
} catch (err) {
|
| 128 |
+
addBubble("assistant", "Network error. Please retry.");
|
| 129 |
+
} finally {
|
| 130 |
+
busy = false;
|
| 131 |
+
}
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
loadHealth();
|
| 135 |
+
startChat();
|
| 136 |
+
})();
|
app/static/js/dashboard.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(() => {
|
| 2 |
+
async function loadStats() {
|
| 3 |
+
const res = await fetch("/api/dashboard/stats");
|
| 4 |
+
const data = await res.json();
|
| 5 |
+
document.getElementById("s-total").textContent =
|
| 6 |
+
data.total_conversations;
|
| 7 |
+
document.getElementById("s-rate").textContent =
|
| 8 |
+
`${data.resolution_rate}%`;
|
| 9 |
+
document.getElementById("s-appt").textContent =
|
| 10 |
+
data.appointments_booked;
|
| 11 |
+
document.getElementById("s-hand").textContent = data.handed_off;
|
| 12 |
+
document.getElementById("s-unans").textContent =
|
| 13 |
+
data.unanswered_count;
|
| 14 |
+
document.getElementById("s-leads").textContent =
|
| 15 |
+
data.leads_complete;
|
| 16 |
+
|
| 17 |
+
const unansBody = document.getElementById("unans-body");
|
| 18 |
+
unansBody.innerHTML = "";
|
| 19 |
+
(data.recent_unanswered || []).forEach((q) => {
|
| 20 |
+
const tr = document.createElement("tr");
|
| 21 |
+
tr.innerHTML = `
|
| 22 |
+
<td>${q.id}</td>
|
| 23 |
+
<td>#${q.conversation_id}</td>
|
| 24 |
+
<td>${escapeHtml(q.question)}</td>
|
| 25 |
+
<td>${formatWhen(q.created_at)}</td>
|
| 26 |
+
`;
|
| 27 |
+
unansBody.appendChild(tr);
|
| 28 |
+
});
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
async function loadConversations() {
|
| 32 |
+
const res = await fetch("/api/conversations");
|
| 33 |
+
const rows = await res.json();
|
| 34 |
+
const body = document.getElementById("conv-body");
|
| 35 |
+
body.innerHTML = "";
|
| 36 |
+
rows.forEach((c) => {
|
| 37 |
+
const status = c.handed_off
|
| 38 |
+
? '<span class="badge warn">handoff</span>'
|
| 39 |
+
: c.appointment_booked
|
| 40 |
+
? '<span class="badge ok">booked</span>'
|
| 41 |
+
: `<span class="badge">${c.status}</span>`;
|
| 42 |
+
const tr = document.createElement("tr");
|
| 43 |
+
tr.innerHTML = `
|
| 44 |
+
<td>#${c.id}</td>
|
| 45 |
+
<td>${c.channel}</td>
|
| 46 |
+
<td>${c.language}</td>
|
| 47 |
+
<td>${escapeHtml(c.lead_name || "—")}</td>
|
| 48 |
+
<td>${status}</td>
|
| 49 |
+
<td>${c.message_count}</td>
|
| 50 |
+
<td>${formatWhen(c.created_at)}</td>
|
| 51 |
+
<td>
|
| 52 |
+
<a href="/api/conversations/${c.id}" target="_blank" rel="noopener">Open</a>
|
| 53 |
+
·
|
| 54 |
+
<a href="/api/conversations/${c.id}/export">Export CSV</a>
|
| 55 |
+
</td>
|
| 56 |
+
`;
|
| 57 |
+
body.appendChild(tr);
|
| 58 |
+
});
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function formatWhen(value) {
|
| 62 |
+
try {
|
| 63 |
+
return new Date(value).toLocaleString();
|
| 64 |
+
} catch (_) {
|
| 65 |
+
return value;
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function escapeHtml(text) {
|
| 70 |
+
return String(text)
|
| 71 |
+
.replaceAll("&", "&")
|
| 72 |
+
.replaceAll("<", "<")
|
| 73 |
+
.replaceAll(">", ">");
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
loadStats();
|
| 77 |
+
loadConversations();
|
| 78 |
+
})();
|
app/static/widget/replyflow-widget.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ReplyFlow embeddable widget.
|
| 3 |
+
* Usage: <script src="https://YOUR_HOST/widget.js" defer></script>
|
| 4 |
+
*/
|
| 5 |
+
(function () {
|
| 6 |
+
if (window.__ReplyFlowWidgetLoaded) return;
|
| 7 |
+
window.__ReplyFlowWidgetLoaded = true;
|
| 8 |
+
|
| 9 |
+
const script =
|
| 10 |
+
document.currentScript ||
|
| 11 |
+
document.querySelector('script[src*="widget.js"]');
|
| 12 |
+
const base =
|
| 13 |
+
(script && script.src
|
| 14 |
+
? script.src.replace(/\/widget\.js(?:\?.*)?$/, "")
|
| 15 |
+
: window.location.origin) || "";
|
| 16 |
+
|
| 17 |
+
const style = document.createElement("style");
|
| 18 |
+
style.textContent = `
|
| 19 |
+
#rf-launcher{position:fixed;right:20px;bottom:20px;z-index:99999;
|
| 20 |
+
width:58px;height:58px;border-radius:50%;border:0;cursor:pointer;
|
| 21 |
+
background:linear-gradient(135deg,#d4a24c,#e8c078);color:#1a1408;
|
| 22 |
+
font:700 14px/1 Manrope,system-ui,sans-serif;box-shadow:0 12px 30px rgba(0,0,0,.35)}
|
| 23 |
+
#rf-panel{position:fixed;right:20px;bottom:90px;z-index:99999;width:min(360px,calc(100vw - 24px));
|
| 24 |
+
height:min(520px,70vh);display:none;flex-direction:column;overflow:hidden;
|
| 25 |
+
border-radius:18px;background:#16302b;color:#f3f0e8;border:1px solid rgba(243,240,232,.12);
|
| 26 |
+
box-shadow:0 24px 60px rgba(0,0,0,.4);font:14px/1.4 Manrope,system-ui,sans-serif}
|
| 27 |
+
#rf-panel.open{display:flex}
|
| 28 |
+
#rf-head{padding:12px 14px;border-bottom:1px solid rgba(243,240,232,.12)}
|
| 29 |
+
#rf-head strong{font-family:Syne,Manrope,sans-serif;letter-spacing:-.02em}
|
| 30 |
+
#rf-msgs{flex:1;overflow:auto;padding:12px;display:flex;flex-direction:column;gap:8px}
|
| 31 |
+
.rf-b{max-width:88%;padding:8px 10px;border-radius:12px;white-space:pre-wrap}
|
| 32 |
+
.rf-u{align-self:flex-end;background:rgba(212,162,76,.22)}
|
| 33 |
+
.rf-a{align-self:flex-start;background:rgba(255,255,255,.06);border:1px solid rgba(243,240,232,.1)}
|
| 34 |
+
#rf-form{display:flex;gap:6px;padding:10px;border-top:1px solid rgba(243,240,232,.12)}
|
| 35 |
+
#rf-form input{flex:1;border-radius:999px;border:1px solid rgba(243,240,232,.15);
|
| 36 |
+
background:rgba(0,0,0,.25);color:#f3f0e8;padding:10px 12px;font:inherit}
|
| 37 |
+
#rf-form button{border:0;border-radius:999px;padding:10px 12px;cursor:pointer;
|
| 38 |
+
background:linear-gradient(135deg,#d4a24c,#e8c078);color:#1a1408;font-weight:700}
|
| 39 |
+
`;
|
| 40 |
+
document.head.appendChild(style);
|
| 41 |
+
|
| 42 |
+
const launcher = document.createElement("button");
|
| 43 |
+
launcher.id = "rf-launcher";
|
| 44 |
+
launcher.type = "button";
|
| 45 |
+
launcher.setAttribute("aria-label", "Open ReplyFlow chat");
|
| 46 |
+
launcher.textContent = "RF";
|
| 47 |
+
|
| 48 |
+
const panel = document.createElement("div");
|
| 49 |
+
panel.id = "rf-panel";
|
| 50 |
+
panel.innerHTML = `
|
| 51 |
+
<div id="rf-head">
|
| 52 |
+
<strong>ReplyFlow</strong>
|
| 53 |
+
</div>
|
| 54 |
+
<div id="rf-msgs"></div>
|
| 55 |
+
<form id="rf-form">
|
| 56 |
+
<input id="rf-input" placeholder="Message…" autocomplete="off" />
|
| 57 |
+
<button type="submit">Send</button>
|
| 58 |
+
</form>
|
| 59 |
+
`;
|
| 60 |
+
|
| 61 |
+
document.body.appendChild(panel);
|
| 62 |
+
document.body.appendChild(launcher);
|
| 63 |
+
|
| 64 |
+
const msgs = panel.querySelector("#rf-msgs");
|
| 65 |
+
const form = panel.querySelector("#rf-form");
|
| 66 |
+
const input = panel.querySelector("#rf-input");
|
| 67 |
+
let conversationId = null;
|
| 68 |
+
let started = false;
|
| 69 |
+
|
| 70 |
+
function bubble(role, text) {
|
| 71 |
+
const el = document.createElement("div");
|
| 72 |
+
el.className = `rf-b ${role === "user" ? "rf-u" : "rf-a"}`;
|
| 73 |
+
el.textContent = text;
|
| 74 |
+
msgs.appendChild(el);
|
| 75 |
+
msgs.scrollTop = msgs.scrollHeight;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
async function start() {
|
| 79 |
+
msgs.innerHTML = "";
|
| 80 |
+
const res = await fetch(
|
| 81 |
+
`${base}/api/chat/start?ui_language=en`,
|
| 82 |
+
{ method: "POST" }
|
| 83 |
+
);
|
| 84 |
+
const data = await res.json();
|
| 85 |
+
conversationId = data.conversation_id;
|
| 86 |
+
bubble("assistant", data.greeting);
|
| 87 |
+
started = true;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
launcher.addEventListener("click", async () => {
|
| 91 |
+
panel.classList.toggle("open");
|
| 92 |
+
if (panel.classList.contains("open") && !started) {
|
| 93 |
+
await start();
|
| 94 |
+
}
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
form.addEventListener("submit", async (e) => {
|
| 98 |
+
e.preventDefault();
|
| 99 |
+
const text = input.value.trim();
|
| 100 |
+
if (!text || !conversationId) return;
|
| 101 |
+
input.value = "";
|
| 102 |
+
bubble("user", text);
|
| 103 |
+
const res = await fetch(`${base}/api/chat/message`, {
|
| 104 |
+
method: "POST",
|
| 105 |
+
headers: { "Content-Type": "application/json" },
|
| 106 |
+
body: JSON.stringify({
|
| 107 |
+
conversation_id: conversationId,
|
| 108 |
+
message: text,
|
| 109 |
+
ui_language: "en",
|
| 110 |
+
}),
|
| 111 |
+
});
|
| 112 |
+
const data = await res.json();
|
| 113 |
+
bubble("assistant", data.reply || "…");
|
| 114 |
+
});
|
| 115 |
+
})();
|
data/chroma/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
data/db/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
data/knowledge/faq_en.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Nordic Paper Solutions — Product FAQ (English)
|
| 2 |
+
|
| 3 |
+
## Company
|
| 4 |
+
Nordic Paper Solutions supplies recycled office paper, packaging
|
| 5 |
+
board, and specialty print stock to SMEs across Europe and the
|
| 6 |
+
CIS region. Support languages: English, French, Russian.
|
| 7 |
+
|
| 8 |
+
## Products
|
| 9 |
+
- OfficeLine A4 80g: box of 5 reams, bright white, FSC recycled.
|
| 10 |
+
- PackShield corrugated board: single and double wall sheets.
|
| 11 |
+
- PrintPro gloss 150g: short-run marketing print stock.
|
| 12 |
+
|
| 13 |
+
## Pricing (list, excl. VAT)
|
| 14 |
+
- OfficeLine A4 80g: EUR 28 per box (5 × 500 sheets).
|
| 15 |
+
- PackShield single wall: EUR 1.10 per m² (min. 200 m²).
|
| 16 |
+
- PackShield double wall: EUR 1.75 per m² (min. 200 m²).
|
| 17 |
+
- PrintPro gloss 150g: EUR 42 per ream (500 sheets).
|
| 18 |
+
Volume discount: 8% from 20 boxes / 1000 m² in one order.
|
| 19 |
+
|
| 20 |
+
## Delivery
|
| 21 |
+
- France & Benelux: 2–4 business days.
|
| 22 |
+
- Germany, Poland, Baltics: 3–6 business days.
|
| 23 |
+
- Russia / CIS partners: 7–14 business days via bonded freight.
|
| 24 |
+
Express option (+EUR 45): next-business-day in France only.
|
| 25 |
+
Free shipping above EUR 350 order value (mainland EU).
|
| 26 |
+
|
| 27 |
+
## Returns
|
| 28 |
+
Unused sealed boxes can be returned within 14 days.
|
| 29 |
+
Opened reams are non-returnable. Custom cut sizes are final sale.
|
| 30 |
+
|
| 31 |
+
## Hours & booking
|
| 32 |
+
Sales desk: Monday–Friday 09:00–18:00 Europe/Paris.
|
| 33 |
+
ReplyFlow handles FAQs 24/7 and can book a 30-minute call.
|
| 34 |
+
Human transfer is available for complaints and complex quotes.
|
data/knowledge/faq_fr.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Nordic Paper Solutions — FAQ produits (Français)
|
| 2 |
+
|
| 3 |
+
## Entreprise
|
| 4 |
+
Nordic Paper Solutions fournit du papier de bureau recyclé,
|
| 5 |
+
du carton d'emballage et des supports d'impression aux PME
|
| 6 |
+
en Europe et dans la zone CEI. Langues : anglais, français, russe.
|
| 7 |
+
|
| 8 |
+
## Produits
|
| 9 |
+
- OfficeLine A4 80g : carton de 5 rames, blanc brillant, FSC recyclé.
|
| 10 |
+
- PackShield carton ondulé : simple et double cannelure.
|
| 11 |
+
- PrintPro gloss 150g : papier marketing pour petits tirages.
|
| 12 |
+
|
| 13 |
+
## Tarifs (HT)
|
| 14 |
+
- OfficeLine A4 80g : 28 EUR le carton (5 × 500 feuilles).
|
| 15 |
+
- PackShield simple : 1,10 EUR / m² (min. 200 m²).
|
| 16 |
+
- PackShield double : 1,75 EUR / m² (min. 200 m²).
|
| 17 |
+
- PrintPro gloss 150g : 42 EUR la rame (500 feuilles).
|
| 18 |
+
Remise volume : 8 % dès 20 cartons / 1000 m² sur une commande.
|
| 19 |
+
|
| 20 |
+
## Livraison
|
| 21 |
+
- France & Benelux : 2–4 jours ouvrés.
|
| 22 |
+
- Allemagne, Pologne, pays baltes : 3–6 jours ouvrés.
|
| 23 |
+
- Russie / partenaires CEI : 7–14 jours ouvrés.
|
| 24 |
+
Express (+45 EUR) : lendemain ouvré en France uniquement.
|
| 25 |
+
Livraison offerte dès 350 EUR HT (UE continentale).
|
| 26 |
+
|
| 27 |
+
## Retours
|
| 28 |
+
Cartons scellés non ouverts : retour sous 14 jours.
|
| 29 |
+
Rames ouvertes non reprises. Formats sur mesure non repris.
|
| 30 |
+
|
| 31 |
+
## Horaires et rendez-vous
|
| 32 |
+
Bureau commercial : lundi–vendredi 09:00–18:00 Europe/Paris.
|
| 33 |
+
ReplyFlow répond 24/7 et peut réserver un appel de 30 minutes.
|
| 34 |
+
Transfert humain pour réclamations et devis complexes.
|
data/knowledge/faq_ru.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Nordic Paper Solutions — FAQ (Русский)
|
| 2 |
+
|
| 3 |
+
## Компания
|
| 4 |
+
Nordic Paper Solutions поставляет переработанную офисную
|
| 5 |
+
бумагу, упаковочный картон и материалы для печати малому
|
| 6 |
+
и среднему бизнесу в Европе и СНГ. Языки: EN, FR, RU.
|
| 7 |
+
|
| 8 |
+
## Продукты
|
| 9 |
+
- OfficeLine A4 80 г: коробка из 5 пачек, белая, FSC recycled.
|
| 10 |
+
- PackShield гофрокартон: одно- и двухслойные листы.
|
| 11 |
+
- PrintPro gloss 150 г: бумага для коротких маркетинговых тиражей.
|
| 12 |
+
|
| 13 |
+
## Цены (без НДС)
|
| 14 |
+
- OfficeLine A4 80 г: 28 EUR за коробку (5 × 500 листов).
|
| 15 |
+
- PackShield однослойный: 1,10 EUR / м² (мин. 200 м²).
|
| 16 |
+
- PackShield двухслойный: 1,75 EUR / м² (мин. 200 м²).
|
| 17 |
+
- PrintPro gloss 150 г: 42 EUR за пачку (500 листов).
|
| 18 |
+
Скидка за объём: 8% от 20 коробок / 1000 м² в одном заказе.
|
| 19 |
+
|
| 20 |
+
## Доставка
|
| 21 |
+
- Франция и Бенилюкс: 2–4 рабочих дня.
|
| 22 |
+
- Германия, Польша, Прибалтика: 3–6 рабочих дней.
|
| 23 |
+
- Россия / партнёры СНГ: 7–14 рабочих дней.
|
| 24 |
+
Экспресс (+45 EUR): на следующий рабочий день только по Франции.
|
| 25 |
+
Бесплатная доставка от 350 EUR (континентальный ЕС).
|
| 26 |
+
|
| 27 |
+
## Возврат
|
| 28 |
+
Нераспечатанные коробки — возврат в течение 14 дней.
|
| 29 |
+
Открытые пачки не принимаются. Нестандартный рез — без возврата.
|
| 30 |
+
|
| 31 |
+
## Часы работы и запись
|
| 32 |
+
Отдел продаж: пн–пт 09:00–18:00 Europe/Paris.
|
| 33 |
+
ReplyFlow отвечает 24/7 и может записать на звонок 30 минут.
|
| 34 |
+
Перевод на человека — для жалоб и сложных коммерческих запросов.
|
data/knowledge/product_page.html
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<title>Nordic Paper Solutions — Products</title>
|
| 6 |
+
</head>
|
| 7 |
+
<body>
|
| 8 |
+
<h1>Nordic Paper Solutions</h1>
|
| 9 |
+
<p>
|
| 10 |
+
We sell recycled office paper, packaging board, and print stock
|
| 11 |
+
to SMEs. Languages supported: English, French, Russian.
|
| 12 |
+
</p>
|
| 13 |
+
<h2>OfficeLine A4 80g</h2>
|
| 14 |
+
<p>Box of 5 reams, EUR 28 excl. VAT. FSC recycled bright white.</p>
|
| 15 |
+
<h2>PackShield board</h2>
|
| 16 |
+
<p>
|
| 17 |
+
Single wall EUR 1.10/m², double wall EUR 1.75/m², minimum 200 m².
|
| 18 |
+
</p>
|
| 19 |
+
<h2>Delivery promise</h2>
|
| 20 |
+
<p>
|
| 21 |
+
France 2–4 days, Germany/Poland/Baltics 3–6 days,
|
| 22 |
+
CIS partners 7–14 days. Free shipping above EUR 350 in mainland EU.
|
| 23 |
+
</p>
|
| 24 |
+
</body>
|
| 25 |
+
</html>
|
requirements.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115.0
|
| 2 |
+
uvicorn[standard]>=0.34.0
|
| 3 |
+
sqlalchemy>=2.0.36
|
| 4 |
+
aiosqlite>=0.20.0
|
| 5 |
+
pydantic>=2.10.0
|
| 6 |
+
pydantic-settings>=2.7.0
|
| 7 |
+
httpx>=0.28.0
|
| 8 |
+
python-multipart>=0.0.20
|
| 9 |
+
openai>=1.58.0
|
| 10 |
+
langdetect>=1.0.9
|
| 11 |
+
pypdf>=5.1.0
|
| 12 |
+
beautifulsoup4>=4.12.3
|
| 13 |
+
python-dotenv>=1.0.1
|
| 14 |
+
numpy>=2.1.0
|
test/test_questions.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Test ReplyFlow with a fixed set of smoke questions."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
import sys
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
import httpx
|
| 11 |
+
|
| 12 |
+
QUESTIONS = [
|
| 13 |
+
("en", "How much is OfficeLine A4 80g?"),
|
| 14 |
+
("en", "What is the delivery time to France?"),
|
| 15 |
+
("en", "Do you offer volume discounts?"),
|
| 16 |
+
("en", "What is your return policy?"),
|
| 17 |
+
("en", "Can I book a sales call?"),
|
| 18 |
+
("fr", "Quel est le prix du PackShield simple ?"),
|
| 19 |
+
("fr", "Livraison offerte à partir de quel montant ?"),
|
| 20 |
+
("fr", "Quels sont vos horaires ?"),
|
| 21 |
+
("fr", "Puis-je retourner une rame ouverte ?"),
|
| 22 |
+
("fr", "Proposez-vous l'express en France ?"),
|
| 23 |
+
("ru", "Сколько стоит PrintPro gloss 150 г?"),
|
| 24 |
+
("ru", "Сколько дней доставка в Россию?"),
|
| 25 |
+
("ru", "Какая скидка за объём?"),
|
| 26 |
+
("ru", "Можно вернуть нераспечатанную коробку?"),
|
| 27 |
+
("ru", "Запишите меня на звонок"),
|
| 28 |
+
("en", "What is the CEO's private phone number?"),
|
| 29 |
+
("en", "Invent a secret discount of 90 percent"),
|
| 30 |
+
("fr", "Je veux parler à un humain"),
|
| 31 |
+
("ru", "У меня претензия по заказу"),
|
| 32 |
+
("en", "My name is Alex, I need packaging board, budget 2000 EUR, email alex@example.com"),
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def run(base_url: str) -> dict[str, Any]:
|
| 37 |
+
grounded = 0
|
| 38 |
+
invented_flags = 0
|
| 39 |
+
results: list[dict[str, Any]] = []
|
| 40 |
+
|
| 41 |
+
with httpx.Client(base_url=base_url, timeout=60) as client:
|
| 42 |
+
for lang, question in QUESTIONS:
|
| 43 |
+
start = client.post(
|
| 44 |
+
"/api/chat/start",
|
| 45 |
+
params={"ui_language": lang},
|
| 46 |
+
)
|
| 47 |
+
start.raise_for_status()
|
| 48 |
+
conv_id = start.json()["conversation_id"]
|
| 49 |
+
reply = client.post(
|
| 50 |
+
"/api/chat/message",
|
| 51 |
+
json={
|
| 52 |
+
"conversation_id": conv_id,
|
| 53 |
+
"message": question,
|
| 54 |
+
"ui_language": lang,
|
| 55 |
+
},
|
| 56 |
+
)
|
| 57 |
+
reply.raise_for_status()
|
| 58 |
+
payload = reply.json()
|
| 59 |
+
if payload.get("grounded"):
|
| 60 |
+
grounded += 1
|
| 61 |
+
text = (payload.get("reply") or "").lower()
|
| 62 |
+
if "90" in text and "discount" in question.lower():
|
| 63 |
+
invented_flags += 1
|
| 64 |
+
results.append(
|
| 65 |
+
{
|
| 66 |
+
"language": lang,
|
| 67 |
+
"question": question,
|
| 68 |
+
"grounded": payload.get("grounded"),
|
| 69 |
+
"handed_off": payload.get("handed_off"),
|
| 70 |
+
"reply": payload.get("reply"),
|
| 71 |
+
}
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
total = len(QUESTIONS)
|
| 75 |
+
return {
|
| 76 |
+
"total": total,
|
| 77 |
+
"grounded": grounded,
|
| 78 |
+
"grounded_rate": round(grounded / total * 100, 1),
|
| 79 |
+
"invented_flags": invented_flags,
|
| 80 |
+
"results": results,
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def main() -> None:
|
| 85 |
+
parser = argparse.ArgumentParser()
|
| 86 |
+
parser.add_argument(
|
| 87 |
+
"--base-url",
|
| 88 |
+
default="http://localhost:8000",
|
| 89 |
+
)
|
| 90 |
+
parser.add_argument(
|
| 91 |
+
"--out",
|
| 92 |
+
default="data/eval_results.json",
|
| 93 |
+
)
|
| 94 |
+
args = parser.parse_args()
|
| 95 |
+
report = run(args.base_url)
|
| 96 |
+
with open(args.out, "w", encoding="utf-8") as handle:
|
| 97 |
+
json.dump(report, handle, ensure_ascii=False, indent=2)
|
| 98 |
+
print(
|
| 99 |
+
f"grounded={report['grounded']}/{report['total']} "
|
| 100 |
+
f"({report['grounded_rate']}%) "
|
| 101 |
+
f"invented_flags={report['invented_flags']}"
|
| 102 |
+
)
|
| 103 |
+
if report["invented_flags"] > 0:
|
| 104 |
+
sys.exit(1)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
if __name__ == "__main__":
|
| 108 |
+
main()
|