feat: multi-provider AI support with per-provider model selection
- Add llm-factory.ts: unified LLM provider abstraction (Google/Claude/OpenAI) - Install @langchain/anthropic and @langchain/openai packages - resolveAiProvider picks active provider from DB settings (Claude > OpenAI > Google) - All AI graphs (reminder, new-patient, reschedule, internal-chat) now accept provider+model params - Add claudeAiModel, openAiModel, googleAiModel columns to ai_settings table - New PUT /api/ai/provider-model route to save selected model per provider - UI model dropdowns for Claude (Haiku/Sonnet/Opus), OpenAI (GPT-5.x series), Google (Gemini 2.5/3.x) - Google AI section also gets model selector alongside existing API key field Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
||||
import { getLlm, type AiProvider } from "./llm-factory";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
import { storage } from "../storage";
|
||||
import {
|
||||
@@ -83,6 +83,8 @@ function messageHasTime(msg: string): boolean {
|
||||
export async function parseDateOnlyFromMessage(
|
||||
message: string,
|
||||
apiKey: string,
|
||||
provider: AiProvider = "google",
|
||||
model?: string
|
||||
): Promise<{ date: Date; dateLabel: string } | null> {
|
||||
const now = new Date();
|
||||
const todayStr = now.toLocaleDateString("en-CA");
|
||||
@@ -106,9 +108,9 @@ export async function parseDateOnlyFromMessage(
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini fallback for "next Monday", "Tuesday", etc.
|
||||
// LLM fallback for "next Monday", "Tuesday", etc.
|
||||
try {
|
||||
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
|
||||
const llm = getLlm(provider, apiKey, model);
|
||||
const res = await llm.invoke([
|
||||
{
|
||||
role: "system",
|
||||
@@ -145,6 +147,8 @@ Return ONLY raw JSON: {"date":"YYYY-MM-DD"}
|
||||
async function parseDatetimeFromMessage(
|
||||
message: string,
|
||||
apiKey: string,
|
||||
provider: AiProvider = "google",
|
||||
model?: string
|
||||
): Promise<{ date: Date; startTime: string; displayLabel: string } | null> {
|
||||
const now = new Date();
|
||||
const todayStr = now.toLocaleDateString("en-CA"); // YYYY-MM-DD local
|
||||
@@ -188,9 +192,9 @@ async function parseDatetimeFromMessage(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 2: fall back to Google AI for natural language ───────────────────
|
||||
// ── Step 2: fall back to AI for natural language ───────────────────────────
|
||||
try {
|
||||
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
|
||||
const llm = getLlm(provider, apiKey, model);
|
||||
const res = await llm.invoke([
|
||||
{
|
||||
role: "system",
|
||||
@@ -387,7 +391,7 @@ async function isSlotAvailable(
|
||||
|
||||
// ── Time parsing (legacy) ─────────────────────────────────────────────────────
|
||||
|
||||
export async function parseTime(message: string, apiKey: string): Promise<string | null> {
|
||||
export async function parseTime(message: string, apiKey: string, provider: AiProvider = "google", model?: string): Promise<string | null> {
|
||||
const t = message.toLowerCase();
|
||||
if (/\bmorning\b|mañana|manhã|上午|صباح|maten/i.test(t)) return "09:00";
|
||||
if (/\bafternoon\b|tarde|après-midi|下午|مساء|aprèmidi/i.test(t)) return "13:00";
|
||||
@@ -402,7 +406,7 @@ export async function parseTime(message: string, apiKey: string): Promise<string
|
||||
}
|
||||
if (clock) return clock[0]!;
|
||||
try {
|
||||
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
|
||||
const llm = getLlm(provider, apiKey, model);
|
||||
const res = await llm.invoke([
|
||||
{ role: "system", content: 'Extract the time from the message. Return ONLY a 24-hour time in "HH:MM" format. If no time is mentioned, return "null".' },
|
||||
{ role: "user", content: message },
|
||||
@@ -465,9 +469,9 @@ function prefersWednesday(t: string) { return /wednesday|miércoles|quarta|周
|
||||
|
||||
// ── LLM helper ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function llmReply(system: string, user: string, fallback: string, apiKey: string): Promise<string> {
|
||||
async function llmReply(system: string, user: string, fallback: string, apiKey: string, provider: AiProvider = "google", model?: string): Promise<string> {
|
||||
try {
|
||||
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
|
||||
const llm = getLlm(provider, apiKey, model);
|
||||
const res = await llm.invoke([
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: user },
|
||||
@@ -499,6 +503,8 @@ export async function runRescheduleStep(
|
||||
patientId: number,
|
||||
apiKey: string,
|
||||
userId: number = 0,
|
||||
provider: AiProvider = "google",
|
||||
model?: string
|
||||
): Promise<{ reply: string; nextStage: ConversationStage }> {
|
||||
|
||||
const lang = language || "English";
|
||||
@@ -513,7 +519,7 @@ export async function runRescheduleStep(
|
||||
|
||||
if (!hasTime) {
|
||||
// Try to parse date only
|
||||
const parsedDate = await parseDateOnlyFromMessage(message, apiKey);
|
||||
const parsedDate = await parseDateOnlyFromMessage(message, apiKey, provider, model);
|
||||
if (!parsedDate) {
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: "I didn't catch that. What day would you prefer? For example: 'Monday', 'next Tuesday', or '5/18'.",
|
||||
@@ -559,13 +565,13 @@ export async function runRescheduleStep(
|
||||
`You are a friendly dental office assistant. The patient wants to reschedule to ${dateLabel} but hasn't given a time. Ask them in ${lang} what time they prefer on that day. 1 sentence, no formatting.`,
|
||||
`Patient wants ${dateLabel} but gave no time.`,
|
||||
fallbacks[lang] ?? fallbacks["English"]!,
|
||||
apiKey,
|
||||
apiKey, provider, model
|
||||
);
|
||||
return { reply: askTimeReply, nextStage: "asked_reschedule_time_for_date" };
|
||||
}
|
||||
|
||||
// Both date and time present — parse the full datetime
|
||||
const parsed = await parseDatetimeFromMessage(message, apiKey);
|
||||
const parsed = await parseDatetimeFromMessage(message, apiKey, provider, model);
|
||||
if (!parsed) {
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: "I didn't catch that. What day and time would you prefer? For example: 'Monday at 10am' or '5/18 at 2pm'.",
|
||||
@@ -613,7 +619,7 @@ export async function runRescheduleStep(
|
||||
`You are a friendly dental office AI assistant. The patient mentioned a date/time preference that you interpreted as "${displayLabel}". Ask them in ${lang} to confirm: "Do you mean ${displayLabel}?" 1 sentence, natural and friendly. No formatting.`,
|
||||
`Patient said: "${message}"`,
|
||||
fallbacks[lang] ?? fallbacks["English"]!,
|
||||
apiKey,
|
||||
apiKey, provider, model
|
||||
);
|
||||
return { reply: confirmReply, nextStage: "asked_reschedule_confirm_datetime" };
|
||||
}
|
||||
@@ -636,7 +642,7 @@ export async function runRescheduleStep(
|
||||
}
|
||||
|
||||
// Parse the time from the patient's reply
|
||||
const startTime = await parseTime(message, apiKey);
|
||||
const startTime = await parseTime(message, apiKey, provider, model);
|
||||
if (!startTime) {
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: `I didn't catch the time. What time would you prefer on ${pending.dayLabel}? For example: '10am', '2pm', or '1:30pm'.`,
|
||||
@@ -698,7 +704,7 @@ export async function runRescheduleStep(
|
||||
`You are a friendly dental office AI assistant. The patient wants ${fullLabel}. Ask them in ${lang} to confirm: "Do you mean ${fullLabel}?" 1 sentence, natural and friendly. No formatting.`,
|
||||
`Patient said: "${message}"`,
|
||||
fallbacks[lang] ?? fallbacks["English"]!,
|
||||
apiKey,
|
||||
apiKey, provider, model
|
||||
);
|
||||
return { reply: confirmReply, nextStage: "asked_reschedule_confirm_datetime" };
|
||||
}
|
||||
@@ -812,7 +818,7 @@ export async function runRescheduleStep(
|
||||
`You are a friendly dental office AI assistant. The patient's appointment has been successfully moved to ${displayLabel}. Write a warm confirmation message in ${lang}: tell them the appointment is moved to ${displayLabel}, and that our dental receptionist will confirm it with them tomorrow. 2 sentences max, no formatting.`,
|
||||
`Appointment rescheduled to ${displayLabel}.`,
|
||||
fallback,
|
||||
apiKey,
|
||||
apiKey, provider, model
|
||||
);
|
||||
return { reply, nextStage: "done" };
|
||||
}
|
||||
@@ -832,7 +838,7 @@ export async function runRescheduleStep(
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
const reply = await llmReply(
|
||||
`You are a friendly dental office assistant. The patient does not want to reschedule. Write a warm, brief closing message in ${lang}. 1 sentence, no formatting.`,
|
||||
`Patient said: "${message}"`, fallback, apiKey,
|
||||
`Patient said: "${message}"`, fallback, apiKey, provider, model
|
||||
);
|
||||
return { reply, nextStage: "done" };
|
||||
}
|
||||
@@ -844,7 +850,7 @@ export async function runRescheduleStep(
|
||||
|
||||
if (hasTime) {
|
||||
// Try to parse full datetime from the message
|
||||
const parsed = await parseDatetimeFromMessage(message, apiKey);
|
||||
const parsed = await parseDatetimeFromMessage(message, apiKey, provider, model);
|
||||
if (parsed) {
|
||||
const { date, startTime, displayLabel } = parsed;
|
||||
const dayCheck = await isOfficeDayOpen(date, userId);
|
||||
@@ -873,14 +879,14 @@ export async function runRescheduleStep(
|
||||
};
|
||||
const confirmReply = await llmReply(
|
||||
`You are a friendly dental office AI assistant. The patient mentioned a date/time that you interpreted as "${displayLabel}". Ask them in ${lang} to confirm. 1 sentence, natural and friendly. No formatting.`,
|
||||
`Patient said: "${message}"`, fallbacks[lang] ?? fallbacks["English"]!, apiKey,
|
||||
`Patient said: "${message}"`, fallbacks[lang] ?? fallbacks["English"]!, apiKey, provider
|
||||
);
|
||||
return { reply: confirmReply, nextStage: "asked_reschedule_confirm_datetime" };
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse a date-only from the message
|
||||
const parsedDate = await parseDateOnlyFromMessage(message, apiKey);
|
||||
const parsedDate = await parseDateOnlyFromMessage(message, apiKey, provider, model);
|
||||
if (parsedDate) {
|
||||
const { date, dateLabel } = parsedDate;
|
||||
const dayCheck = await isOfficeDayOpen(date, userId);
|
||||
@@ -909,7 +915,7 @@ export async function runRescheduleStep(
|
||||
};
|
||||
const askTimeReply = await llmReply(
|
||||
`You are a friendly dental office assistant. The patient wants ${dateLabel}. Ask them in ${lang} what time they prefer on that day. 1 sentence, no formatting.`,
|
||||
`Patient wants ${dateLabel} but gave no time.`, fallbacks[lang] ?? fallbacks["English"]!, apiKey,
|
||||
`Patient wants ${dateLabel} but gave no time.`, fallbacks[lang] ?? fallbacks["English"]!, apiKey, provider
|
||||
);
|
||||
return { reply: askTimeReply, nextStage: "asked_reschedule_time_for_date" };
|
||||
}
|
||||
@@ -927,7 +933,7 @@ export async function runRescheduleStep(
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
const reply = await llmReply(
|
||||
`You are a friendly dental office assistant. The patient wants to reschedule. Ask them in ${lang} what day and time they prefer. Give 1-2 examples like "Monday at 10am" or "next Tuesday afternoon". 1-2 sentences, no formatting.`,
|
||||
`Patient wants to reschedule.`, fallback, apiKey,
|
||||
`Patient wants to reschedule.`, fallback, apiKey, provider, model
|
||||
);
|
||||
return { reply, nextStage: "asked_reschedule_datetime" };
|
||||
}
|
||||
@@ -951,7 +957,7 @@ export async function runRescheduleStep(
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
const reply = await llmReply(
|
||||
`You are a friendly dental office assistant. Ask the patient in ${lang} if they can come in tomorrow, ${tomorrow}. 1 sentence, no formatting.`,
|
||||
`Patient wants to reschedule ASAP.`, fallback, apiKey,
|
||||
`Patient wants to reschedule ASAP.`, fallback, apiKey, provider, model
|
||||
);
|
||||
return { reply, nextStage: "asked_reschedule_asap" };
|
||||
}
|
||||
@@ -970,7 +976,7 @@ export async function runRescheduleStep(
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
const reply = await llmReply(
|
||||
`You are a friendly dental office assistant. Offer next week's Monday (${mon}), Tuesday (${tue}), or Wednesday (${wed}) in ${lang}. 1-2 sentences, no formatting.`,
|
||||
`Patient prefers next week.`, fallback, apiKey,
|
||||
`Patient prefers next week.`, fallback, apiKey, provider, model
|
||||
);
|
||||
return { reply, nextStage: "asked_reschedule_next_week" };
|
||||
}
|
||||
@@ -995,7 +1001,7 @@ export async function runRescheduleStep(
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
const reply = await llmReply(
|
||||
`You are a friendly dental office assistant. The patient confirmed ${label}. Ask in ${lang} whether they prefer morning (9am-12pm) or afternoon (1pm-5pm). 1 sentence, no formatting.`,
|
||||
`Patient confirmed tomorrow.`, fallback, apiKey,
|
||||
`Patient confirmed tomorrow.`, fallback, apiKey, provider, model
|
||||
);
|
||||
return { reply, nextStage: "asked_reschedule_time" };
|
||||
}
|
||||
@@ -1013,7 +1019,7 @@ export async function runRescheduleStep(
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
const reply = await llmReply(
|
||||
`You are a friendly dental office assistant. The patient cannot come tomorrow. Offer next week: ${mon}, ${tue}, or ${wed} in ${lang}. 1-2 sentences, no formatting.`,
|
||||
`Patient can't come tomorrow.`, fallback, apiKey,
|
||||
`Patient can't come tomorrow.`, fallback, apiKey, provider, model
|
||||
);
|
||||
return { reply, nextStage: "asked_reschedule_next_week" };
|
||||
}
|
||||
@@ -1043,7 +1049,7 @@ export async function runRescheduleStep(
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
const reply = await llmReply(
|
||||
`You are a friendly dental office assistant. The patient chose ${day}. Ask in ${lang} whether they prefer morning (9am-12pm) or afternoon (1pm-5pm). 1 sentence, no formatting.`,
|
||||
`Patient chose ${day}.`, fallback, apiKey,
|
||||
`Patient chose ${day}.`, fallback, apiKey, provider, model
|
||||
);
|
||||
return { reply, nextStage: "asked_reschedule_time" };
|
||||
}
|
||||
@@ -1066,7 +1072,7 @@ export async function runRescheduleStep(
|
||||
const pending = getPendingReschedule(userId, patientId);
|
||||
if (!pending) return { reply: tx, nextStage: "done" };
|
||||
|
||||
const startTime = await parseTime(message, apiKey);
|
||||
const startTime = await parseTime(message, apiKey, provider, model);
|
||||
if (!startTime) {
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: "I didn't catch the time. Would you prefer morning (9am–12pm) or afternoon (1pm–5pm)?",
|
||||
@@ -1101,7 +1107,7 @@ export async function runRescheduleStep(
|
||||
const fallback = `Your appointment has been moved to ${apptLabel}. Our dental receptionist will confirm it with you tomorrow.`;
|
||||
const reply = await llmReply(
|
||||
`You are a friendly dental office assistant. The patient's appointment has been rescheduled to ${apptLabel}. Confirm in ${lang}: say the appointment is moved to ${apptLabel} and that our dental receptionist will confirm it with them tomorrow. 2 sentences, no formatting.`,
|
||||
`Appointment moved to ${apptLabel}.`, fallback, apiKey,
|
||||
`Appointment moved to ${apptLabel}.`, fallback, apiKey, provider, model
|
||||
);
|
||||
return { reply, nextStage: "done" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user