Skip to content
107 changes: 106 additions & 1 deletion apps/backend/src/channels/whatsapp/whatsapp-intent.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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(
Expand All @@ -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",
Expand All @@ -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 },
});
},
);
});
37 changes: 35 additions & 2 deletions apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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",
Expand Down Expand Up @@ -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") ||
Expand All @@ -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)
) {
Expand All @@ -111,8 +133,7 @@ export class WhatsAppIntentService {
}

private async parseWithGemini(messageText: string): Promise<ParsedIntent> {
const systemPrompt =
this.configService.get<string>("whatsapp.systemPrompt") || "";
const systemPrompt = this.getSystemPrompt();
const functionCallingSystemPrompt = systemPrompt
? `${systemPrompt}\n\n${FORCE_FUNCTION_CALL_PROMPT}`
: FORCE_FUNCTION_CALL_PROMPT;
Expand All @@ -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<string>("whatsapp.systemPrompt") ||
this.configService.get<string>("WHATSAPP_SYSTEM_PROMPT") ||
""
);
}
}
42 changes: 41 additions & 1 deletion apps/backend/src/channels/whatsapp/whatsapp.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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: {
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading