diff --git a/apps/backend/src/channels/whatsapp/whatsapp-intent.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.spec.ts index 6d40d17..27b437a 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-intent.service.spec.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.spec.ts @@ -1,7 +1,11 @@ import { ConfigService } from "@nestjs/config"; import { GeminiClient } from "../../integrations/ai/gemini.client"; -import { GEMINI_FUNCTION_DECLARATIONS } from "./whatsapp.constants"; +import { + GEMINI_FUNCTION_DECLARATIONS, + MINIMAL_WHATSAPP_SYSTEM_PROMPT, +} from "./whatsapp.constants"; import { WhatsAppIntentService } from "./whatsapp-intent.service"; +import { Logger } from "@nestjs/common"; describe("WhatsAppIntentService", () => { const configService = { @@ -13,9 +17,11 @@ describe("WhatsAppIntentService", () => { }; let service: WhatsAppIntentService; + let loggerLogSpy: jest.SpyInstance; beforeEach(() => { jest.clearAllMocks(); + loggerLogSpy = jest.spyOn(Logger.prototype, "log").mockImplementation(); configService.get.mockReturnValue("Base WhatsApp prompt."); geminiClient.isConfigured.mockReturnValue(true); service = new WhatsAppIntentService( @@ -24,6 +30,10 @@ describe("WhatsAppIntentService", () => { ); }); + afterEach(() => { + loggerLogSpy.mockRestore(); + }); + it("forces Gemini intent parsing to return a function call", async () => { geminiClient.parseFunctionCall.mockResolvedValue({ name: "search_products", @@ -47,4 +57,99 @@ describe("WhatsAppIntentService", () => { geminiClient.parseFunctionCall.mock.calls[0][0].systemPrompt, ).toContain("Base WhatsApp prompt."); }); + + it("uses WHATSAPP_SYSTEM_PROMPT when namespaced prompt is missing", async () => { + configService.get.mockImplementation((key: string) => + key === "WHATSAPP_SYSTEM_PROMPT" ? "Raw env prompt fixture." : undefined, + ); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + geminiClient.parseFunctionCall.mockResolvedValue({ + name: "answer_twizrr_question", + args: { + topic: "twizrr_explainer", + answer: "Twizrr helps shoppers discover products from Nigerian stores.", + }, + }); + + await service.parseIntent("what is Twizrr?"); + + expect( + geminiClient.parseFunctionCall.mock.calls[0][0].systemPrompt, + ).toContain("Raw env prompt fixture."); + }); + + it("uses the minimal fallback prompt when env prompt is missing", async () => { + configService.get.mockReturnValue(undefined); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + geminiClient.parseFunctionCall.mockResolvedValue({ + name: "answer_twizrr_question", + args: { + topic: "assistant_identity", + answer: "I am Twizrr's WhatsApp shopping assistant.", + }, + }); + + await service.parseIntent("who are you?"); + + expect( + geminiClient.parseFunctionCall.mock.calls[0][0].systemPrompt, + ).toContain(MINIMAL_WHATSAPP_SYSTEM_PROMPT); + }); + + it("does not log prompt content while reporting prompt presence", () => { + expect(loggerLogSpy).toHaveBeenCalledWith( + "WhatsApp system prompt loaded from env: true", + ); + expect(loggerLogSpy).not.toHaveBeenCalledWith( + expect.stringContaining("Base WhatsApp prompt."), + ); + }); + + it("parses Gemini natural answer function calls", async () => { + geminiClient.parseFunctionCall.mockResolvedValue({ + name: "answer_twizrr_question", + args: { + topic: "creator_company", + answer: + "Twizrr is built by CODEDDEVS TECHNOLOGY LTD. It helps shoppers discover products from Nigerian stores.", + }, + }); + + const intent = await service.parseIntent("who created Twizrr?"); + + expect(intent).toEqual({ + functionName: "answer_twizrr_question", + params: { + topic: "creator_company", + answer: + "Twizrr is built by CODEDDEVS TECHNOLOGY LTD. It helps shoppers discover products from Nigerian stores.", + }, + }); + }); + + it.each([ + "show me iphone 15", + "find bags under 50k", + "can I get phones here", + ])( + "routes product-like fallback text to product search: %s", + async (text) => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + await expect(service.parseIntent(text)).resolves.toEqual({ + functionName: "search_products", + params: { query: text }, + }); + }, + ); }); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts index dda324a..8ebc014 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts @@ -3,6 +3,7 @@ import { ConfigService } from "@nestjs/config"; import { NUMBER_INTENT_MAP, GEMINI_FUNCTION_DECLARATIONS, + MINIMAL_WHATSAPP_SYSTEM_PROMPT, } from "./whatsapp.constants"; import { GeminiClient } from "../../integrations/ai/gemini.client"; @@ -26,11 +27,17 @@ const FORCE_FUNCTION_CALL_PROMPT = @Injectable() export class WhatsAppIntentService { private readonly logger = new Logger(WhatsAppIntentService.name); + private readonly privatePromptConfigured: boolean; constructor( private configService: ConfigService, private geminiClient: GeminiClient, ) { + this.privatePromptConfigured = Boolean(this.getPrivateSystemPrompt()); + this.logger.log( + `WhatsApp system prompt loaded from env: ${this.privatePromptConfigured}`, + ); + if (!this.geminiClient.isConfigured()) { this.logger.warn( "Gemini API key not configured - AI intent parsing disabled, number menu still works", @@ -93,6 +100,8 @@ export class WhatsAppIntentService { if ( lower.includes("buy") || + lower.includes("do you have") || + lower.includes("can i get") || lower.includes("search") || lower.includes("find") || lower.includes("product") || @@ -101,6 +110,19 @@ export class WhatsAppIntentService { return { functionName: "search_products", params: { query: text } }; } + if ( + lower.includes("what is twizrr") || + lower.includes("who are you") || + lower.includes("who created") || + lower.includes("who built") || + lower.includes("buyer protection") || + lower.includes("money safe") || + lower.includes("account linking") || + lower.includes("send a picture") + ) { + return { functionName: "answer_twizrr_question", params: {} }; + } + if ( ["hi", "hello", "hey", "good morning", "good evening"].includes(lower) ) { @@ -111,8 +133,7 @@ export class WhatsAppIntentService { } private async parseWithGemini(messageText: string): Promise { - const systemPrompt = - this.configService.get("whatsapp.systemPrompt") || ""; + const systemPrompt = this.getSystemPrompt(); const functionCallingSystemPrompt = systemPrompt ? `${systemPrompt}\n\n${FORCE_FUNCTION_CALL_PROMPT}` : FORCE_FUNCTION_CALL_PROMPT; @@ -133,4 +154,16 @@ export class WhatsAppIntentService { params: functionCall.args, }; } + + private getSystemPrompt(): string { + return this.getPrivateSystemPrompt() || MINIMAL_WHATSAPP_SYSTEM_PROMPT; + } + + private getPrivateSystemPrompt(): string { + return ( + this.configService.get("whatsapp.systemPrompt") || + this.configService.get("WHATSAPP_SYSTEM_PROMPT") || + "" + ); + } } diff --git a/apps/backend/src/channels/whatsapp/whatsapp.constants.ts b/apps/backend/src/channels/whatsapp/whatsapp.constants.ts index d2e4953..a950ff0 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.constants.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.constants.ts @@ -27,6 +27,13 @@ export const EMAIL_NOT_FOUND = `We couldn't find an account associated with that export const INVALID_OTP = `The code you entered is incorrect. Please check your email and try again.`; export const OTP_EXPIRED = `Your verification code has expired. Please provide your email again to receive a new one.`; export const GENERIC_ERROR = `We encountered a problem while processing your request. Please try again or type "menu" to see available options.`; +export const SAFE_NATURAL_ANSWER_FALLBACK = + "I can help with shopping on Twizrr, product search, buyer protection, account linking, and order-related questions once your account is linked."; +export const SUPPORT_HANDOFF_RESPONSE = + "I can help with shopping questions here. For account or order support, please visit twizrr support in the app or on the web."; + +export const MINIMAL_WHATSAPP_SYSTEM_PROMPT = + "You are Twizrr's WhatsApp shopping assistant. Help shoppers with Twizrr shopping only. You use NO emojis - plain text only. No markdown or bullet points. Keep replies under 150 words. Keep replies short, safe, and grounded. Do not invent product, payment, order, or company details."; export enum SessionState { AWAITING_EMAIL = "AWAITING_EMAIL", @@ -43,7 +50,7 @@ export const GEMINI_FUNCTION_DECLARATIONS = [ { name: "search_products", description: - "Search for products the shopper wants to buy. Triggered by product names, categories, descriptions, or shopping requests.", + "Search for products the shopper wants to buy. Triggered by product names, categories, descriptions, price/location shopping requests, or questions about whether a product category is available.", parameters: { type: "object" as const, properties: { @@ -89,6 +96,39 @@ export const GEMINI_FUNCTION_DECLARATIONS = [ "Show the shopper assistant menu when intent is unclear or the shopper asks for help/menu.", parameters: { type: "object" as const, properties: {} }, }, + { + name: "answer_twizrr_question", + description: + "Answer a Twizrr-related shopping, help, trust, buyer protection, account linking, image search, order tracking explanation, delivery confirmation explanation, support, assistant identity, or company identity question using only approved Twizrr knowledge from the system prompt.", + parameters: { + type: "object" as const, + properties: { + topic: { + type: "string", + enum: [ + "assistant_identity", + "twizrr_explainer", + "creator_company", + "how_it_works", + "buyer_protection", + "account_linking", + "image_search", + "order_tracking", + "delivery_confirmation", + "support", + "other_twizrr_help", + ], + description: "The Twizrr help topic being answered.", + }, + answer: { + type: "string", + description: + "Short WhatsApp-ready plain text answer grounded only in the system prompt. Do not invent details.", + }, + }, + required: ["topic", "answer"], + }, + }, { name: "support_handoff", description: diff --git a/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts index bc418e3..25e4d9c 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts @@ -5,7 +5,10 @@ import { WHATSAPP_CONSENT_DECLINED_MESSAGE, WHATSAPP_CONSENT_MESSAGE, } from "./whatsapp-session.service"; -import { FRIENDLY_FALLBACK } from "./whatsapp.constants"; +import { + FRIENDLY_FALLBACK, + SAFE_NATURAL_ANSWER_FALLBACK, +} from "./whatsapp.constants"; describe("WhatsAppService", () => { const configService = { get: jest.fn() }; @@ -210,6 +213,152 @@ describe("WhatsAppService", () => { ).toHaveBeenCalledWith("2348012345678", "red shoes"); }); + it("allows consented unlinked buy phrasing as product discovery", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + consentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "search_products", + params: { query: "iphone 15" }, + }); + + await service.processMessage( + "2348012345678", + "I want to buy iphone 15", + "message-1", + ); + + expect(authService.handleLinkingFlow).not.toHaveBeenCalled(); + expect( + productDiscoveryService.sendGenericProductSearch, + ).toHaveBeenCalledWith("2348012345678", "iphone 15"); + expect(interactiveService.sendTextMessage).not.toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("Please link your twizrr account"), + ); + }); + + it("sends validated Gemini natural answers for Twizrr questions", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + consentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "answer_twizrr_question", + params: { + topic: "creator_company", + answer: + "Twizrr is built by CODEDDEVS TECHNOLOGY LTD. It helps shoppers discover products from Nigerian stores.", + }, + }); + + await service.processMessage( + "2348012345678", + "who created Twizrr?", + "message-1", + ); + + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("CODEDDEVS TECHNOLOGY LTD"), + ); + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + }); + + it.each([ + "", + " ".repeat(8), + "x".repeat(701), + '{"topic":"creator_company","answer":"Twizrr"}', + "This exposes physicalStoreId to shoppers.", + "Twizrr was founded by Aliameen.", + "This phone is available now for NGN 100000.", + "Happy to help \u{1F600}", + "- Buyer Protection helps you shop safely", + "* Buyer Protection helps you shop safely", + "• Buyer Protection helps you shop safely", + "1. Search products", + "1) Search products", + "# Twizrr", + "**Twizrr** helps shoppers", + "`internal`", + "> quoted text", + ])("falls back when Gemini natural answer is unsafe: %s", async (answer) => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + consentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "answer_twizrr_question", + params: { + topic: "other_twizrr_help", + answer, + }, + }); + + await service.processMessage("2348012345678", "what is this?", "message-1"); + + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + SAFE_NATURAL_ANSWER_FALLBACK, + ); + }); + + it("sends short plain-text Gemini natural answers unchanged", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + consentGiven: true, + }); + const plainAnswer = + "Twizrr helps you find products from Nigerian stores and shop with Buyer Protection."; + intentService.parseIntent.mockResolvedValue({ + functionName: "answer_twizrr_question", + params: { + topic: "other_twizrr_help", + answer: plainAnswer, + }, + }); + + await service.processMessage( + "2348012345678", + "what is Twizrr?", + "message-1", + ); + + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + plainAnswer, + ); + }); + + it("sends a deterministic support response when Gemini selects support handoff", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + consentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "support_handoff", + params: {}, + }); + + await service.processMessage( + "2348012345678", + "talk to support", + "message-1", + ); + + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + "I can help with shopping questions here. For account or order support, please visit twizrr support in the app or on the web.", + ); + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + }); + it("allows consented unlinked image search", async () => { authService.resolvePhone.mockResolvedValue(null); whatsappSessionService.recordInbound.mockResolvedValue({ @@ -315,6 +464,31 @@ describe("WhatsAppService", () => { ); }); + it("asks consented unlinked users to link before cart actions", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + consentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "search_products", + params: { query: "add this to cart" }, + }); + + await service.processMessage( + "2348012345678", + "add this to cart", + "message-1", + ); + + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("Please link your twizrr account"), + ); + }); + it("asks consented unlinked users to link before order tracking", async () => { authService.resolvePhone.mockResolvedValue(null); whatsappSessionService.recordInbound.mockResolvedValue({ diff --git a/apps/backend/src/channels/whatsapp/whatsapp.service.ts b/apps/backend/src/channels/whatsapp/whatsapp.service.ts index 0cbd8e8..7d3c874 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.service.ts @@ -6,7 +6,11 @@ import { WhatsAppAuthService } from "./whatsapp-auth.service"; import { WhatsAppIntentService } from "./whatsapp-intent.service"; import { ImageSearchService } from "./image-search.service"; import { WhatsAppInteractiveService } from "./whatsapp-interactive.service"; -import { FRIENDLY_FALLBACK } from "./whatsapp.constants"; +import { + FRIENDLY_FALLBACK, + SAFE_NATURAL_ANSWER_FALLBACK, + SUPPORT_HANDOFF_RESPONSE, +} from "./whatsapp.constants"; import { WhatsAppProductDiscoveryService } from "./whatsapp-product-discovery.service"; import { WHATSAPP_CONSENT_ACCEPT_ID, @@ -127,7 +131,8 @@ export class WhatsAppService { this.isAccountActionRequest(messageText || "", intent.functionName) && intent.functionName !== "get_order_status" && intent.functionName !== "track_order" && - intent.functionName !== "confirm_delivery" + intent.functionName !== "confirm_delivery" && + intent.functionName !== "answer_twizrr_question" ) { if (!(await this.requireLinkedAccount(phone, userId))) { return; @@ -163,6 +168,20 @@ export class WhatsAppService { await this.sendShopperMenu(phone); return; + case "answer_twizrr_question": + await this.interactiveService.sendTextMessage( + phone, + this.getSafeNaturalAnswer(intent.params), + ); + return; + + case "support_handoff": + await this.interactiveService.sendTextMessage( + phone, + SUPPORT_HANDOFF_RESPONSE, + ); + return; + case "friendly_fallback": default: await this.interactiveService.sendTextMessage( @@ -196,19 +215,35 @@ export class WhatsAppService { } const lower = messageText.toLowerCase(); - return [ - "buy", - "checkout", - "cart", - "wishlist", - "save", - "saved", - "payment", - "pay", - "order", - "delivery confirmation", - "confirm delivery", - ].some((keyword) => lower.includes(keyword)); + const actionKeywords = + functionName === "search_products" + ? [ + "checkout", + "cart", + "wishlist", + "save", + "saved", + "payment", + "pay", + "order", + "delivery confirmation", + "confirm delivery", + ] + : [ + "buy", + "checkout", + "cart", + "wishlist", + "save", + "saved", + "payment", + "pay", + "order", + "delivery confirmation", + "confirm delivery", + ]; + + return actionKeywords.some((keyword) => lower.includes(keyword)); } private getSearchQuery( @@ -219,6 +254,108 @@ export class WhatsAppService { return typeof query === "string" && query.trim() ? query : fallback || ""; } + private getSafeNaturalAnswer(params: Record): string { + const answer = params.answer; + + if (typeof answer !== "string") { + return SAFE_NATURAL_ANSWER_FALLBACK; + } + + const trimmed = answer.trim(); + return this.isSafeNaturalAnswer(trimmed) + ? trimmed + : SAFE_NATURAL_ANSWER_FALLBACK; + } + + private isSafeNaturalAnswer(answer: string): boolean { + if (!answer || answer.length > 700) { + return false; + } + + if (this.countWords(answer) > 150) { + return false; + } + + if ( + this.looksLikeJson(answer) || + this.containsEmoji(answer) || + this.containsMarkdownOrBullets(answer) + ) { + return false; + } + + return ( + !this.containsForbiddenInternalTerm(answer) && + !this.containsUnapprovedFounderClaim(answer) && + !this.containsUnsupportedCommerceClaim(answer) + ); + } + + private countWords(answer: string): number { + const words = answer.trim().split(/\s+/).filter(Boolean); + return words.length; + } + + private containsMarkdownOrBullets(answer: string): boolean { + return ( + /^\s*[-*•]\s+/m.test(answer) || + /^\s*\d+[.)]\s+/m.test(answer) || + /[`*_#~>]/.test(answer) + ); + } + + private looksLikeJson(answer: string): boolean { + const trimmed = answer.trim(); + return ( + (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")) || + /"answer"\s*:|"topic"\s*:/.test(trimmed) + ); + } + + private containsForbiddenInternalTerm(answer: string): boolean { + return [ + "physicalStoreId", + "linkedOrderId", + "sourcedProductId", + "dropshipperCostKobo", + "digitalMarginKobo", + "paystackRecipientCode", + "api key", + "token", + "database url", + ].some((term) => answer.toLowerCase().includes(term.toLowerCase())); + } + + private containsUnapprovedFounderClaim(answer: string): boolean { + const lower = answer.toLowerCase(); + if ( + /\b(aliameen|mustakheem|abdulraseed)\b/i.test(answer) || + /\bfounder\b/.test(lower) + ) { + return true; + } + + return ( + /\b(created by|built by|made by|owned by)\b/.test(lower) && + !answer.includes("CODEDDEVS TECHNOLOGY LTD") + ); + } + + private containsUnsupportedCommerceClaim(answer: string): boolean { + return [ + /\b(in stock|out of stock|available now|we have|we sell)\b/i, + /\b(costs|price is|selling for|available for)\b/i, + /(\u20a6|\bngn\b|\bnaira\b|\bkobo\b)/i, + /\b(arrives on|delivered by|delivery date is)\b/i, + /\bpayment (is|has been) (confirmed|received|failed|pending)\b/i, + ].some((pattern) => pattern.test(answer)); + } + + private containsEmoji(answer: string): boolean { + return /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(answer); + } + private async showTypingIndicator(messageId?: string): Promise { if (!messageId?.trim()) { return;