Commit ·
91c3bff
1
Parent(s): d514191
enhance email normalization and add confirmation previews for appointment updates and deletions
Browse files- core/backend.py +88 -7
- services/tts.py +4 -4
core/backend.py
CHANGED
|
@@ -10,6 +10,7 @@ import pytz
|
|
| 10 |
from datetime import datetime, timedelta
|
| 11 |
from dotenv import load_dotenv
|
| 12 |
import re
|
|
|
|
| 13 |
|
| 14 |
from langchain_core.messages import (
|
| 15 |
AIMessage, AIMessageChunk, HumanMessage, RemoveMessage,
|
|
@@ -58,6 +59,23 @@ def _clean_text(text: str) -> str:
|
|
| 58 |
return re.sub(r"\s+", " ", (text or "").strip())
|
| 59 |
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
_DIGIT_TRANSLATION = str.maketrans({
|
| 62 |
"০": "0",
|
| 63 |
"১": "1",
|
|
@@ -679,7 +697,7 @@ async def book_appointment(
|
|
| 679 |
visiting_date = _clean_text(visiting_date)
|
| 680 |
visiting_day = _clean_text(visiting_day)
|
| 681 |
visiting_time = _clean_text(visiting_time)
|
| 682 |
-
patient_mail =
|
| 683 |
|
| 684 |
if visiting_date:
|
| 685 |
parsed_date = _parse_visit_date(visiting_date)
|
|
@@ -790,6 +808,7 @@ async def book_appointment(
|
|
| 790 |
)
|
| 791 |
mail_status = "\n📧 Confirmation mail sent."
|
| 792 |
except Exception as e:
|
|
|
|
| 793 |
mail_status = f"\n⚠️ Mail failed: {str(e)}"
|
| 794 |
|
| 795 |
return (
|
|
@@ -818,6 +837,7 @@ async def update_appointment(
|
|
| 818 |
new_doctor_name: str = "",
|
| 819 |
new_patient_num: str = "",
|
| 820 |
new_patient_mail: str = "",
|
|
|
|
| 821 |
) -> str:
|
| 822 |
"""
|
| 823 |
Update an existing appointment found by phone number.
|
|
@@ -834,13 +854,14 @@ async def update_appointment(
|
|
| 834 |
(or doctor_id) to select which one to update.
|
| 835 |
- A confirmation email is REQUIRED for updates: either the existing
|
| 836 |
appointment has an email, or provide new_patient_mail.
|
|
|
|
| 837 |
"""
|
| 838 |
db_path = get_db_path()
|
| 839 |
patient_num_norm = format_bd_number(patient_num)
|
| 840 |
selector_name = _clean_text(doctor_name)
|
| 841 |
new_doctor_name = _clean_text(new_doctor_name)
|
| 842 |
new_patient_num = format_bd_number(new_patient_num) if new_patient_num else ""
|
| 843 |
-
new_patient_mail =
|
| 844 |
new_visiting_date = _clean_text(new_visiting_date)
|
| 845 |
|
| 846 |
parsed_date = _parse_visit_date(new_visiting_date) if new_visiting_date else None
|
|
@@ -890,7 +911,7 @@ async def update_appointment(
|
|
| 890 |
else:
|
| 891 |
return f"No doctor found with name '{new_doctor_name}'."
|
| 892 |
|
| 893 |
-
# Build updated fields
|
| 894 |
updated_doctor_name = appt.get("doctor_name", "")
|
| 895 |
updated_doctor_category = appt.get("doctor_category", "")
|
| 896 |
updated_visiting_time = appt.get("visiting_time", "")
|
|
@@ -923,6 +944,40 @@ async def update_appointment(
|
|
| 923 |
if not updated_patient_mail:
|
| 924 |
return "Email is required to update an appointment. Please provide an email address."
|
| 925 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 926 |
await db.execute(
|
| 927 |
"""UPDATE patients
|
| 928 |
SET doctor_name = ?,
|
|
@@ -966,6 +1021,7 @@ async def update_appointment(
|
|
| 966 |
)
|
| 967 |
mail_status = "📧 Confirmation mail sent."
|
| 968 |
except Exception as e:
|
|
|
|
| 969 |
mail_status = f"⚠️ Mail failed: {str(e)}"
|
| 970 |
|
| 971 |
return (
|
|
@@ -989,15 +1045,17 @@ async def delete_appointment(
|
|
| 989 |
doctor_name: str = "",
|
| 990 |
doctor_id: int = 0,
|
| 991 |
patient_mail: str = "",
|
|
|
|
| 992 |
) -> str:
|
| 993 |
"""
|
| 994 |
Cancel (delete) an appointment.
|
| 995 |
Sends a confirmation email to the patient (required).
|
|
|
|
| 996 |
"""
|
| 997 |
db_path = get_db_path()
|
| 998 |
patient_num = format_bd_number(patient_num)
|
| 999 |
doctor_name = _clean_text(doctor_name)
|
| 1000 |
-
patient_mail =
|
| 1001 |
|
| 1002 |
async with aiosqlite.connect(db_path) as db:
|
| 1003 |
db.row_factory = aiosqlite.Row
|
|
@@ -1048,7 +1106,29 @@ async def delete_appointment(
|
|
| 1048 |
"message": "Email is required to cancel an appointment. Please provide the email address.",
|
| 1049 |
}, ensure_ascii=False)
|
| 1050 |
|
| 1051 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1052 |
await db.execute("DELETE FROM patients WHERE id = ?", (appt["id"],))
|
| 1053 |
await db.commit()
|
| 1054 |
|
|
@@ -1071,6 +1151,7 @@ async def delete_appointment(
|
|
| 1071 |
)
|
| 1072 |
mail_status = "Confirmation mail sent."
|
| 1073 |
except Exception as e:
|
|
|
|
| 1074 |
mail_status = f"Mail failed: {str(e)}"
|
| 1075 |
|
| 1076 |
return json.dumps({
|
|
@@ -1185,13 +1266,13 @@ Important update rules:
|
|
| 1185 |
- Ask whether to keep the rest of the existing appointment unchanged.
|
| 1186 |
- If multiple appointments exist for a phone number, ask for the doctor name to select the correct one.
|
| 1187 |
- Email is REQUIRED to update. If the existing record has no email, ask for it.
|
| 1188 |
-
-
|
| 1189 |
|
| 1190 |
Important cancellation rules:
|
| 1191 |
- If the user gave only a phone number and there is exactly one matching appointment, cancel it directly.
|
| 1192 |
- If multiple appointments match, ask only for the doctor name.
|
| 1193 |
- Email is REQUIRED to cancel or update. If missing, ask for email.
|
| 1194 |
-
-
|
| 1195 |
|
| 1196 |
Do not give a normal conversational answer before the tool call.
|
| 1197 |
"""
|
|
|
|
| 10 |
from datetime import datetime, timedelta
|
| 11 |
from dotenv import load_dotenv
|
| 12 |
import re
|
| 13 |
+
import traceback
|
| 14 |
|
| 15 |
from langchain_core.messages import (
|
| 16 |
AIMessage, AIMessageChunk, HumanMessage, RemoveMessage,
|
|
|
|
| 59 |
return re.sub(r"\s+", " ", (text or "").strip())
|
| 60 |
|
| 61 |
|
| 62 |
+
def _normalize_email(text: str) -> str:
|
| 63 |
+
"""
|
| 64 |
+
Best-effort normalizer for dictated emails like:
|
| 65 |
+
"rakib dot hedigital at gmail dot com"
|
| 66 |
+
Keeps it conservative: only applies if obvious patterns exist.
|
| 67 |
+
"""
|
| 68 |
+
raw = _clean_text(text).lower()
|
| 69 |
+
if not raw:
|
| 70 |
+
return ""
|
| 71 |
+
# Common voice dictation patterns
|
| 72 |
+
raw = raw.replace(" at ", "@").replace(" dot ", ".")
|
| 73 |
+
raw = raw.replace(" underscore ", "_").replace(" dash ", "-")
|
| 74 |
+
raw = raw.replace(" minus ", "-").replace(" plus ", "+")
|
| 75 |
+
raw = raw.replace(" ", "")
|
| 76 |
+
return raw
|
| 77 |
+
|
| 78 |
+
|
| 79 |
_DIGIT_TRANSLATION = str.maketrans({
|
| 80 |
"০": "0",
|
| 81 |
"১": "1",
|
|
|
|
| 697 |
visiting_date = _clean_text(visiting_date)
|
| 698 |
visiting_day = _clean_text(visiting_day)
|
| 699 |
visiting_time = _clean_text(visiting_time)
|
| 700 |
+
patient_mail = _normalize_email(patient_mail)
|
| 701 |
|
| 702 |
if visiting_date:
|
| 703 |
parsed_date = _parse_visit_date(visiting_date)
|
|
|
|
| 808 |
)
|
| 809 |
mail_status = "\n📧 Confirmation mail sent."
|
| 810 |
except Exception as e:
|
| 811 |
+
traceback.print_exc()
|
| 812 |
mail_status = f"\n⚠️ Mail failed: {str(e)}"
|
| 813 |
|
| 814 |
return (
|
|
|
|
| 837 |
new_doctor_name: str = "",
|
| 838 |
new_patient_num: str = "",
|
| 839 |
new_patient_mail: str = "",
|
| 840 |
+
confirm: bool = False,
|
| 841 |
) -> str:
|
| 842 |
"""
|
| 843 |
Update an existing appointment found by phone number.
|
|
|
|
| 854 |
(or doctor_id) to select which one to update.
|
| 855 |
- A confirmation email is REQUIRED for updates: either the existing
|
| 856 |
appointment has an email, or provide new_patient_mail.
|
| 857 |
+
- IMPORTANT: This tool will NOT change the database unless confirm=True.
|
| 858 |
"""
|
| 859 |
db_path = get_db_path()
|
| 860 |
patient_num_norm = format_bd_number(patient_num)
|
| 861 |
selector_name = _clean_text(doctor_name)
|
| 862 |
new_doctor_name = _clean_text(new_doctor_name)
|
| 863 |
new_patient_num = format_bd_number(new_patient_num) if new_patient_num else ""
|
| 864 |
+
new_patient_mail = _normalize_email(new_patient_mail)
|
| 865 |
new_visiting_date = _clean_text(new_visiting_date)
|
| 866 |
|
| 867 |
parsed_date = _parse_visit_date(new_visiting_date) if new_visiting_date else None
|
|
|
|
| 911 |
else:
|
| 912 |
return f"No doctor found with name '{new_doctor_name}'."
|
| 913 |
|
| 914 |
+
# Build updated fields (proposal)
|
| 915 |
updated_doctor_name = appt.get("doctor_name", "")
|
| 916 |
updated_doctor_category = appt.get("doctor_category", "")
|
| 917 |
updated_visiting_time = appt.get("visiting_time", "")
|
|
|
|
| 944 |
if not updated_patient_mail:
|
| 945 |
return "Email is required to update an appointment. Please provide an email address."
|
| 946 |
|
| 947 |
+
# If not confirmed, return a preview only (no DB changes).
|
| 948 |
+
if not confirm:
|
| 949 |
+
preview = {
|
| 950 |
+
"success": False,
|
| 951 |
+
"needs_confirmation": True,
|
| 952 |
+
"message": (
|
| 953 |
+
"I found your appointment. Please confirm the changes. "
|
| 954 |
+
"If you want to keep the rest of the existing information, "
|
| 955 |
+
"say YES and confirm."
|
| 956 |
+
),
|
| 957 |
+
"current": {
|
| 958 |
+
"doctor_name": appt.get("doctor_name", ""),
|
| 959 |
+
"doctor_category": appt.get("doctor_category", ""),
|
| 960 |
+
"patient_name": appt.get("patient_name", ""),
|
| 961 |
+
"patient_num": appt.get("patient_num", ""),
|
| 962 |
+
"patient_mail": appt.get("patient_mail", ""),
|
| 963 |
+
"visiting_date": appt.get("visiting_date", ""),
|
| 964 |
+
"visiting_day": appt.get("visiting_day", ""),
|
| 965 |
+
"visiting_time": appt.get("visiting_time", ""),
|
| 966 |
+
},
|
| 967 |
+
"proposed": {
|
| 968 |
+
"doctor_name": updated_doctor_name,
|
| 969 |
+
"doctor_category": updated_doctor_category,
|
| 970 |
+
"patient_name": appt.get("patient_name", ""),
|
| 971 |
+
"patient_num": updated_patient_num,
|
| 972 |
+
"patient_mail": updated_patient_mail,
|
| 973 |
+
"visiting_date": updated_visiting_date,
|
| 974 |
+
"visiting_day": updated_visiting_day,
|
| 975 |
+
"visiting_time": updated_visiting_time,
|
| 976 |
+
},
|
| 977 |
+
}
|
| 978 |
+
return json.dumps(preview, ensure_ascii=False)
|
| 979 |
+
|
| 980 |
+
# Confirmed: apply update
|
| 981 |
await db.execute(
|
| 982 |
"""UPDATE patients
|
| 983 |
SET doctor_name = ?,
|
|
|
|
| 1021 |
)
|
| 1022 |
mail_status = "📧 Confirmation mail sent."
|
| 1023 |
except Exception as e:
|
| 1024 |
+
traceback.print_exc()
|
| 1025 |
mail_status = f"⚠️ Mail failed: {str(e)}"
|
| 1026 |
|
| 1027 |
return (
|
|
|
|
| 1045 |
doctor_name: str = "",
|
| 1046 |
doctor_id: int = 0,
|
| 1047 |
patient_mail: str = "",
|
| 1048 |
+
confirm: bool = False,
|
| 1049 |
) -> str:
|
| 1050 |
"""
|
| 1051 |
Cancel (delete) an appointment.
|
| 1052 |
Sends a confirmation email to the patient (required).
|
| 1053 |
+
IMPORTANT: This tool will NOT delete anything unless confirm=True.
|
| 1054 |
"""
|
| 1055 |
db_path = get_db_path()
|
| 1056 |
patient_num = format_bd_number(patient_num)
|
| 1057 |
doctor_name = _clean_text(doctor_name)
|
| 1058 |
+
patient_mail = _normalize_email(patient_mail)
|
| 1059 |
|
| 1060 |
async with aiosqlite.connect(db_path) as db:
|
| 1061 |
db.row_factory = aiosqlite.Row
|
|
|
|
| 1106 |
"message": "Email is required to cancel an appointment. Please provide the email address.",
|
| 1107 |
}, ensure_ascii=False)
|
| 1108 |
|
| 1109 |
+
if not confirm:
|
| 1110 |
+
preview = {
|
| 1111 |
+
"success": False,
|
| 1112 |
+
"needs_confirmation": True,
|
| 1113 |
+
"message": (
|
| 1114 |
+
"I found your appointment. Please confirm cancellation. "
|
| 1115 |
+
"If you want to keep the rest of the existing information, "
|
| 1116 |
+
"you don't need to provide anything else—just confirm."
|
| 1117 |
+
),
|
| 1118 |
+
"current": {
|
| 1119 |
+
"doctor_name": appt.get("doctor_name", doctor_name),
|
| 1120 |
+
"doctor_category": appt.get("doctor_category", ""),
|
| 1121 |
+
"patient_name": appt.get("patient_name", ""),
|
| 1122 |
+
"patient_num": appt.get("patient_num", patient_num),
|
| 1123 |
+
"patient_mail": appt_email,
|
| 1124 |
+
"visiting_date": appt.get("visiting_date", ""),
|
| 1125 |
+
"visiting_day": appt.get("visiting_day", ""),
|
| 1126 |
+
"visiting_time": appt.get("visiting_time", ""),
|
| 1127 |
+
},
|
| 1128 |
+
}
|
| 1129 |
+
return json.dumps(preview, ensure_ascii=False)
|
| 1130 |
+
|
| 1131 |
+
# Confirmed: delete after we have all details for mail
|
| 1132 |
await db.execute("DELETE FROM patients WHERE id = ?", (appt["id"],))
|
| 1133 |
await db.commit()
|
| 1134 |
|
|
|
|
| 1151 |
)
|
| 1152 |
mail_status = "Confirmation mail sent."
|
| 1153 |
except Exception as e:
|
| 1154 |
+
traceback.print_exc()
|
| 1155 |
mail_status = f"Mail failed: {str(e)}"
|
| 1156 |
|
| 1157 |
return json.dumps({
|
|
|
|
| 1266 |
- Ask whether to keep the rest of the existing appointment unchanged.
|
| 1267 |
- If multiple appointments exist for a phone number, ask for the doctor name to select the correct one.
|
| 1268 |
- Email is REQUIRED to update. If the existing record has no email, ask for it.
|
| 1269 |
+
- To avoid accidental changes: call `update_appointment` first with confirm=false to get a preview, show it to the user, then call again with confirm=true only after final confirmation.
|
| 1270 |
|
| 1271 |
Important cancellation rules:
|
| 1272 |
- If the user gave only a phone number and there is exactly one matching appointment, cancel it directly.
|
| 1273 |
- If multiple appointments match, ask only for the doctor name.
|
| 1274 |
- Email is REQUIRED to cancel or update. If missing, ask for email.
|
| 1275 |
+
- To avoid accidental deletion: call `delete_appointment` first with confirm=false to get a preview, show it to the user, then call again with confirm=true only after final confirmation.
|
| 1276 |
|
| 1277 |
Do not give a normal conversational answer before the tool call.
|
| 1278 |
"""
|
services/tts.py
CHANGED
|
@@ -45,9 +45,9 @@ def _parse_pct(text: str) -> float:
|
|
| 45 |
# Effective speed = base * (1 + pct).
|
| 46 |
# - `ELEVENLABS_SPEED_MAX` sets the upper clamp (default 3.0). If your ElevenLabs
|
| 47 |
# model/voice rejects high values, lower this (e.g. 2.5).
|
| 48 |
-
_ELEVEN_BASE_SPEED = float(os.getenv("ELEVENLABS_SPEED", "2.
|
| 49 |
_ELEVEN_SPEED_PCT = _parse_pct(os.getenv("ELEVENLABS_SPEED_PCT", "0%"))
|
| 50 |
-
_ELEVEN_SPEED_MAX = float(os.getenv("ELEVENLABS_SPEED_MAX", "3.
|
| 51 |
ELEVENLABS_SPEED = _clamp(_ELEVEN_BASE_SPEED * (1.0 + _ELEVEN_SPEED_PCT), 0.5, _ELEVEN_SPEED_MAX)
|
| 52 |
ELEVENLABS_OUTPUT_FORMAT = "mp3_22050_32"
|
| 53 |
ELEVENLABS_STABILITY = 0.45
|
|
@@ -93,7 +93,7 @@ def split_sentences(text: str) -> list[str]:
|
|
| 93 |
return [p.strip() for p in parts if len(p.strip()) > 1]
|
| 94 |
|
| 95 |
|
| 96 |
-
async def _edge_tts_stream(text: str, voice: str = EDGE_VOICE, rate: str = "+
|
| 97 |
"""
|
| 98 |
Stream Edge-TTS audio for a single text chunk.
|
| 99 |
Default rate is slightly faster than normal.
|
|
@@ -173,7 +173,7 @@ async def _elevenlabs_stream(
|
|
| 173 |
async def text_to_speech_stream(
|
| 174 |
text: str,
|
| 175 |
voice: str | None = None,
|
| 176 |
-
rate: str = "+
|
| 177 |
):
|
| 178 |
"""
|
| 179 |
Stream TTS audio for `text`.
|
|
|
|
| 45 |
# Effective speed = base * (1 + pct).
|
| 46 |
# - `ELEVENLABS_SPEED_MAX` sets the upper clamp (default 3.0). If your ElevenLabs
|
| 47 |
# model/voice rejects high values, lower this (e.g. 2.5).
|
| 48 |
+
_ELEVEN_BASE_SPEED = float(os.getenv("ELEVENLABS_SPEED", "2.8"))
|
| 49 |
_ELEVEN_SPEED_PCT = _parse_pct(os.getenv("ELEVENLABS_SPEED_PCT", "0%"))
|
| 50 |
+
_ELEVEN_SPEED_MAX = float(os.getenv("ELEVENLABS_SPEED_MAX", "3.5"))
|
| 51 |
ELEVENLABS_SPEED = _clamp(_ELEVEN_BASE_SPEED * (1.0 + _ELEVEN_SPEED_PCT), 0.5, _ELEVEN_SPEED_MAX)
|
| 52 |
ELEVENLABS_OUTPUT_FORMAT = "mp3_22050_32"
|
| 53 |
ELEVENLABS_STABILITY = 0.45
|
|
|
|
| 93 |
return [p.strip() for p in parts if len(p.strip()) > 1]
|
| 94 |
|
| 95 |
|
| 96 |
+
async def _edge_tts_stream(text: str, voice: str = EDGE_VOICE, rate: str = "+22%"):
|
| 97 |
"""
|
| 98 |
Stream Edge-TTS audio for a single text chunk.
|
| 99 |
Default rate is slightly faster than normal.
|
|
|
|
| 173 |
async def text_to_speech_stream(
|
| 174 |
text: str,
|
| 175 |
voice: str | None = None,
|
| 176 |
+
rate: str = "+22%",
|
| 177 |
):
|
| 178 |
"""
|
| 179 |
Stream TTS audio for `text`.
|