8747 characters
import OpenAI from "openai";
import { prisma } from "@/lib/prisma";
import { owlyTools, executeToolCall } from "./tools";
import { emitNewMessage } from "@/lib/realtime";
import { analyzeSentiment, detectIntent, estimateConfidence, requiresHumanApproval } from "./guardrails";
import type {
AIMessage,
AIConfig,
ConversationContext,
KnowledgeItem,
} from "./types";
function buildSystemPrompt(context: ConversationContext): string {
const toneGuide: Record<string, string> = {
friendly:
"Be warm, approachable, and conversational. Use a casual but professional tone.",
professional:
"Be polished and business-like. Maintain a confident, competent tone while remaining personable.",
formal:
"Be professional, polished, and courteous. Use formal language and proper grammar.",
technical:
"Be precise and detailed. Use technical terminology when appropriate and provide thorough explanations.",
};
const knowledgeSection =
context.knowledgeBase.length > 0
? context.knowledgeBase
.sort((a, b) => b.priority - a.priority)
.map(
(k) =>
`[${k.category}] ${k.title}:\n${k.content}`
)
.join("\n\n---\n\n")
: "No specific knowledge base entries available. Answer based on general knowledge about the business.";
return `You are Owly, the AI customer support assistant for ${context.businessName}.
${context.businessDesc ? `About the business: ${context.businessDesc}` : ""}
## Communication Style
${toneGuide[context.tone] || toneGuide.friendly}
${context.language !== "auto" ? `Always respond in: ${context.language}` : "Respond in the same language the customer uses."}
## Your Knowledge Base
Use the following information to answer customer questions accurately:
${knowledgeSection}
## Important Guidelines
- Always be helpful and try to resolve the customer's issue
- If you cannot answer a question from the knowledge base, honestly say so and offer to connect them with a team member
- Use the create_ticket tool when a customer reports a problem that needs human intervention
- Use send_internal_email to notify relevant team members about urgent issues
- Use get_customer_history to check if the customer has contacted before
- Never make up information that isn't in your knowledge base
- Keep responses concise but thorough
- The customer is contacting via: ${context.channel}
${context.customerName !== "Unknown" ? `- Customer name: ${context.customerName}` : ""}
## Customer History
${context.customerHistory.length > 0 ? context.customerHistory.join("\n") : "This is the customer's first interaction."}`;
}
async function getKnowledgeBase(): Promise<KnowledgeItem[]> {
const entries = await prisma.knowledgeEntry.findMany({
where: { isActive: true },
include: { category: true },
orderBy: { priority: "desc" },
});
return entries.map((e: { category: { name: string }; title: string; content: string; priority: number }) => ({
category: e.category.name,
title: e.title,
content: e.content,
priority: e.priority,
}));
}
async function getAIConfig(): Promise<AIConfig & ConversationContext> {
let settings = await prisma.settings.findFirst();
if (!settings) {
settings = await prisma.settings.create({ data: { id: "default" } });
}
return {
provider: settings.aiProvider,
model: settings.aiModel,
apiKey: settings.aiApiKey,
maxTokens: settings.maxTokens,
temperature: settings.temperature,
businessName: settings.businessName,
businessDesc: settings.businessDesc,
welcomeMessage: settings.welcomeMessage,
tone: settings.tone,
language: settings.language,
knowledgeBase: [],
customerName: "",
customerHistory: [],
channel: "",
};
}
export async function chat(
conversationId: string,
userMessage: string
): Promise<string> {
const config = await getAIConfig();
if (!config.apiKey) {
return "AI is not configured. Please add your API key in Settings > AI Configuration.";
}
const conversation = await prisma.conversation.findUnique({
where: { id: conversationId },
include: {
messages: { orderBy: { createdAt: "asc" }, take: 50 },
},
});
if (!conversation) {
return "Conversation not found.";
}
const knowledgeBase = await getKnowledgeBase();
const context: ConversationContext = {
...config,
knowledgeBase,
customerName: conversation.customerName,
channel: conversation.channel,
customerHistory: [],
};
// Build message history
const messages: AIMessage[] = [
{ role: "system", content: buildSystemPrompt(context) },
];
for (const msg of conversation.messages) {
if (msg.role === "customer") {
messages.push({ role: "user", content: msg.content });
} else if (msg.role === "assistant") {
messages.push({ role: "assistant", content: msg.content });
}
}
messages.push({ role: "user", content: userMessage });
// Guardrails: check if human approval needed
const approval = requiresHumanApproval(userMessage);
if (approval.required) {
const sentiment = analyzeSentiment(userMessage);
const intent = detectIntent(userMessage);
// Store metadata for dashboard visibility
await prisma.conversation.update({
where: { id: conversationId },
data: {
metadata: {
escalationReason: approval.reason,
sentiment: sentiment.sentiment,
intent: intent.intent,
},
},
});
}
// Save user message
await prisma.message.create({
data: {
conversationId,
role: "customer",
content: userMessage,
},
});
// Call AI
const response = await callAI(config, messages, conversationId);
// Save assistant message
const savedMessage = await prisma.message.create({
data: {
conversationId,
role: "assistant",
content: response,
},
});
// Update conversation timestamp
await prisma.conversation.update({
where: { id: conversationId },
data: { updatedAt: new Date() },
});
// Confidence scoring
const confidence = estimateConfidence(response, knowledgeBase.length, false);
if (confidence.shouldEscalate) {
await prisma.conversation.update({
where: { id: conversationId },
data: { status: "escalated" },
});
}
emitNewMessage(conversationId, { id: savedMessage.id, role: "assistant", content: response });
return response;
}
async function callAI(
config: AIConfig,
messages: AIMessage[],
conversationId: string,
depth = 0
): Promise<string> {
if (depth > 5) {
return "I apologize, but I'm having trouble processing your request. Let me connect you with a team member.";
}
const openai = new OpenAI({ apiKey: config.apiKey });
let response;
try {
response = await openai.chat.completions.create({
model: config.model,
messages: messages as OpenAI.ChatCompletionMessageParam[],
tools: owlyTools as OpenAI.ChatCompletionTool[],
max_tokens: config.maxTokens,
temperature: config.temperature,
});
} catch {
return "I'm temporarily unable to process your request. Please try again in a moment, or I can connect you with a team member.";
}
const choice = response.choices[0];
if (
choice.finish_reason === "tool_calls" &&
choice.message.tool_calls?.length
) {
// Process tool calls
const toolCalls = choice.message.tool_calls as Array<{
id: string;
type: string;
function: { name: string; arguments: string };
}>;
messages.push({
role: "assistant",
content: choice.message.content || "",
tool_calls: toolCalls.map((tc) => ({
id: tc.id,
type: "function" as const,
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
})),
});
for (const toolCall of toolCalls) {
const args = JSON.parse(toolCall.function.arguments);
const result = await executeToolCall(
toolCall.function.name,
args,
conversationId
);
messages.push({
role: "tool",
content: result,
tool_call_id: toolCall.id,
});
}
// Continue the conversation with tool results
return callAI(config, messages, conversationId, depth + 1);
}
return choice.message.content || "I apologize, I could not generate a response.";
}
export async function createNewConversation(
channel: string,
customerName: string,
customerContact: string,
customerId?: string
) {
return prisma.conversation.create({
data: {
channel,
customerName,
customerContact,
...(customerId && { customerId }),
},
});
}
7276 characters
/**
* AI Guardrails - Controls what the AI can and cannot do.
*/
export interface GuardrailConfig {
blockedTopics: string[];
maxResponseLength: number;
requireHumanApproval: string[];
confidenceThreshold: number;
}
const DEFAULT_GUARDRAILS: GuardrailConfig = {
blockedTopics: [
"legal advice",
"medical advice",
"investment advice",
"price commitments",
"competitor comparisons",
],
maxResponseLength: 2000,
requireHumanApproval: [
"refund",
"cancellation",
"discount",
"compensation",
"legal",
],
confidenceThreshold: 0.6,
};
/**
* Check if a message contains blocked topics.
*/
export function checkBlockedTopics(
message: string,
config: GuardrailConfig = DEFAULT_GUARDRAILS
): { blocked: boolean; topic?: string } {
const lower = message.toLowerCase();
for (const topic of config.blockedTopics) {
if (lower.includes(topic.toLowerCase())) {
return { blocked: true, topic };
}
}
return { blocked: false };
}
/**
* Check if a message requires human approval before AI responds.
*/
export function requiresHumanApproval(
message: string,
config: GuardrailConfig = DEFAULT_GUARDRAILS
): { required: boolean; reason?: string } {
const lower = message.toLowerCase();
for (const keyword of config.requireHumanApproval) {
if (lower.includes(keyword.toLowerCase())) {
return { required: true, reason: keyword };
}
}
return { required: false };
}
/**
* Truncate AI response to maximum allowed length.
*/
export function enforceResponseLength(
response: string,
config: GuardrailConfig = DEFAULT_GUARDRAILS
): string {
if (response.length <= config.maxResponseLength) return response;
return response.substring(0, config.maxResponseLength).trimEnd() + "...";
}
/**
* Analyze sentiment of a message.
* Returns: positive, negative, neutral, or frustrated
*/
export function analyzeSentiment(message: string): {
sentiment: "positive" | "negative" | "neutral" | "frustrated";
score: number;
} {
const lower = message.toLowerCase();
const negativePatterns = [
"angry", "furious", "terrible", "worst", "hate", "awful",
"disgusting", "unacceptable", "ridiculous", "horrible",
"scam", "fraud", "sue", "lawyer", "complaint",
];
const frustratedPatterns = [
"again", "still", "waiting", "how long", "not working",
"broken", "frustrated", "annoyed", "disappointed",
"already told", "third time", "keep asking",
];
const positivePatterns = [
"thank", "great", "excellent", "awesome", "love",
"perfect", "amazing", "wonderful", "fantastic", "helpful",
"appreciate", "satisfied", "happy",
];
let score = 0;
for (const p of negativePatterns) {
if (lower.includes(p)) score -= 2;
}
for (const p of frustratedPatterns) {
if (lower.includes(p)) score -= 1;
}
for (const p of positivePatterns) {
if (lower.includes(p)) score += 2;
}
if (score <= -3) return { sentiment: "negative", score: Math.max(-1, score / 10) };
if (score <= -1) return { sentiment: "frustrated", score: score / 10 };
if (score >= 2) return { sentiment: "positive", score: Math.min(1, score / 10) };
return { sentiment: "neutral", score: 0 };
}
/**
* Detect customer intent from message.
*/
export function detectIntent(message: string): {
intent: string;
confidence: number;
} {
const lower = message.toLowerCase();
const intents: { intent: string; keywords: string[]; weight: number }[] = [
{ intent: "support", keywords: ["help", "issue", "problem", "not working", "broken", "error", "bug", "fix"], weight: 1 },
{ intent: "billing", keywords: ["invoice", "bill", "charge", "payment", "refund", "price", "cost", "subscription", "plan"], weight: 1 },
{ intent: "sales", keywords: ["buy", "purchase", "pricing", "demo", "trial", "interested", "quote", "proposal"], weight: 1 },
{ intent: "complaint", keywords: ["complaint", "unhappy", "dissatisfied", "terrible", "worst", "unacceptable", "sue"], weight: 1.5 },
{ intent: "information", keywords: ["how", "what", "when", "where", "can i", "do you", "is it", "tell me"], weight: 0.5 },
{ intent: "cancellation", keywords: ["cancel", "unsubscribe", "stop", "close account", "terminate", "end service"], weight: 1.5 },
{ intent: "feedback", keywords: ["suggest", "feedback", "improve", "feature request", "would be nice", "wish"], weight: 1 },
{ intent: "greeting", keywords: ["hello", "hi", "hey", "good morning", "good afternoon"], weight: 0.3 },
];
let bestMatch = { intent: "general", confidence: 0 };
for (const { intent, keywords, weight } of intents) {
let matchCount = 0;
for (const kw of keywords) {
if (lower.includes(kw)) matchCount++;
}
const confidence = Math.min(1, (matchCount * weight) / 3);
if (confidence > bestMatch.confidence) {
bestMatch = { intent, confidence };
}
}
return bestMatch;
}
/**
* Generate a conversation summary from messages.
*/
export function generateSummaryPrompt(
messages: { role: string; content: string }[]
): string {
const transcript = messages
.slice(-20)
.map((m) => `${m.role}: ${m.content.substring(0, 300)}`)
.join("\n");
return `Summarize this customer support conversation in 2-3 sentences. Focus on: what the customer needed, what was done, and the outcome.
Conversation:
${transcript}
Summary:`;
}
/**
* Calculate AI confidence score based on response characteristics.
*/
export function estimateConfidence(
response: string,
knowledgeBaseSize: number,
hasToolCalls: boolean
): { score: number; shouldEscalate: boolean } {
let score = 0.5;
// Knowledge base coverage
if (knowledgeBaseSize > 20) score += 0.1;
if (knowledgeBaseSize > 50) score += 0.1;
// Response quality indicators
if (response.length > 50 && response.length < 1500) score += 0.1;
if (hasToolCalls) score += 0.1;
// Uncertainty indicators
const uncertainPhrases = [
"i'm not sure", "i don't know", "i cannot", "i apologize",
"unfortunately", "i'm unable", "beyond my knowledge",
"connect you with", "team member",
];
const lower = response.toLowerCase();
for (const phrase of uncertainPhrases) {
if (lower.includes(phrase)) {
score -= 0.15;
break;
}
}
score = Math.max(0, Math.min(1, score));
return {
score: Math.round(score * 100) / 100,
shouldEscalate: score < DEFAULT_GUARDRAILS.confidenceThreshold,
};
}
/**
* Generate suggested replies for an agent based on conversation context.
*/
export function generateSuggestedRepliesPrompt(
messages: { role: string; content: string }[],
cannedResponses: { title: string; content: string }[]
): string {
const lastMessages = messages.slice(-5);
const transcript = lastMessages
.map((m) => `${m.role}: ${m.content.substring(0, 200)}`)
.join("\n");
const canned = cannedResponses
.slice(0, 5)
.map((c) => `- ${c.title}: ${c.content.substring(0, 100)}`)
.join("\n");
return `Based on this conversation, suggest 3 short professional replies the support agent could send. Each reply should be 1-2 sentences.
Recent messages:
${transcript}
${canned ? `Available templates:\n${canned}\n` : ""}
Return exactly 3 suggestions as a JSON array of strings.`;
}