nvikou commited on
Commit
582520a
·
verified ·
1 Parent(s): 09e5995

Initial commit

Browse files
.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: gray
5
- colorTo: pink
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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("&", "&amp;")
72
+ .replaceAll("<", "&lt;")
73
+ .replaceAll(">", "&gt;");
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()