Files

382 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from "vitest";
import {
Agent,
MEMORY_KEY_SUMMARY,
MEMORY_KEY_LAST_QUERIES,
isRepeatQuery,
normalizeQuery,
} from "../src/ai/agent/agent.js";
import type { LlmProvider } from "../src/ai/providers/llm.js";
import type { Db } from "../src/db/db.js";
import type { Retriever, RetrievalResult } from "../src/ai/retrieval/retriever.js";
import type { ConversationMemory } from "../src/ai/memory/memory.js";
import type { Summarizer } from "../src/ai/memory/summarizer.js";
class FakeLlm implements LlmProvider {
calls: { system: string; messages: { role: string; content: string }[] }[] = [];
async chat(args: {
system: string;
messages: { role: "user" | "assistant"; content: string }[];
}): Promise<string> {
this.calls.push(args);
return "mock reply";
}
async chatSmall(): Promise<string> {
return "small reply";
}
}
function fakeDb(): Db {
return {
botDbName: "bot",
pool: {} as never,
saveExchange: async () => ({ conversationId: 1, userId: 1 }),
recentMessages: async () => [
{ role: "user", content: "earlier question" },
{ role: "assistant", content: "earlier answer" },
],
memorySet: async () => {},
memoryGet: async () => undefined,
memoryGetAll: async () => ({}),
close: async () => {},
};
}
class FakeMemory implements ConversationMemory {
store = new Map<string, string>();
failRecallAll = false;
async remember(userId: number, key: string, value: string): Promise<void> {
this.store.set(`${userId}:${key}`, value);
}
async recall(userId: number, key: string): Promise<string | undefined> {
return this.store.get(`${userId}:${key}`);
}
async recallAll(userId: number): Promise<Record<string, string>> {
if (this.failRecallAll) throw new Error("recall boom");
const out: Record<string, string> = {};
for (const [k, v] of this.store) {
if (k.startsWith(`${userId}:`)) out[k.slice(`${userId}:`.length)] = v;
}
return out;
}
seed(userId: number, key: string, value: string) {
this.store.set(`${userId}:${key}`, value);
}
}
class FakeSummarizer implements Summarizer {
calls: {
priorSummary?: string;
priorQueries: string[];
currentTurn: string;
}[] = [];
result = "remembered summary";
fail = false;
async extract(args: {
priorSummary?: string;
priorQueries: string[];
currentTurn: string;
}): Promise<string | undefined> {
this.calls.push(args);
if (this.fail) throw new Error("summarizer boom");
return this.result;
}
}
function respondArgs(overrides: Partial<{ userText: string; userId: number }> = {}) {
return {
conversationId: 1,
userId: 1,
userText: "do you have courses?",
preferredLanguage: "en",
...overrides,
};
}
describe("Agent.respond", () => {
it("assembles system + history + current message", async () => {
const llm = new FakeLlm();
const agent = new Agent(llm, fakeDb());
const reply = await agent.respond(respondArgs());
expect(reply).toBe("mock reply");
expect(llm.calls).toHaveLength(1);
const call = llm.calls[0];
expect(call.system).toContain("Digi Kedai");
expect(call.messages[call.messages.length - 1]).toEqual({
role: "user",
content: "do you have courses?",
});
// History should be present
expect(call.messages.some((m) => m.content === "earlier question")).toBe(true);
});
it("injects retrieved product facts into the system prompt", async () => {
const llm = new FakeLlm();
const retriever: Retriever = {
retrieve: async (q, topK): Promise<RetrievalResult[]> => [
{
text: `得到 全平台(SKU CDD01)→ https://www.digikedai.com/products/cdd01/`,
source: "https://www.digikedai.com/products/cdd01/",
score: 1,
},
],
};
const agent = new Agent(llm, fakeDb(), retriever);
await agent.respond(respondArgs({ userText: "do you have CDD01?" }));
const call = llm.calls[0];
expect(call.system).toContain("Product facts");
expect(call.system).toContain("https://www.digikedai.com/products/cdd01/");
});
it("keeps the system prompt clean when retrieval returns nothing", async () => {
const llm = new FakeLlm();
const agent = new Agent(llm, fakeDb());
await agent.respond(respondArgs({ userText: "hello" }));
const call = llm.calls[0];
expect(call.system).not.toContain("Product facts");
});
it("injects remembered user summary into the system prompt", async () => {
const llm = new FakeLlm();
const memory = new FakeMemory();
memory.seed(1, MEMORY_KEY_SUMMARY, "customer speaks Chinese, likes CDD01");
const agent = new Agent(llm, fakeDb(), undefined, memory);
await agent.respond(respondArgs());
const call = llm.calls[0];
expect(call.system).toContain("User memory");
expect(call.system).toContain("customer speaks Chinese, likes CDD01");
});
it("replying stays safe when memory recall fails", async () => {
const llm = new FakeLlm();
const memory = new FakeMemory();
memory.failRecallAll = true;
const agent = new Agent(llm, fakeDb(), undefined, memory);
const reply = await agent.respond(respondArgs());
expect(reply).toBe("mock reply");
const call = llm.calls[0];
expect(call.system).not.toContain("User memory");
});
it("injects the repeat-question hint when the same question was asked before", async () => {
const llm = new FakeLlm();
const memory = new FakeMemory();
memory.seed(
1,
MEMORY_KEY_LAST_QUERIES,
JSON.stringify(["do you have courses?"]),
);
const agent = new Agent(llm, fakeDb(), undefined, memory);
await agent.respond(respondArgs({ userText: "Do you have courses?" }));
expect(llm.calls[0].system).toContain("repeat-question rule");
});
it("does not flag a fresh question as a repeat", async () => {
const llm = new FakeLlm();
const memory = new FakeMemory();
memory.seed(
1,
MEMORY_KEY_LAST_QUERIES,
JSON.stringify(["do you have courses?"]),
);
const agent = new Agent(llm, fakeDb(), undefined, memory);
await agent.respond(respondArgs({ userText: "how much is the template?" }));
expect(llm.calls[0].system).not.toContain("repeat-question rule");
});
it("stays silent (empty reply) on WhatsApp when the model emits NO_REPLY", async () => {
const silentLlm: LlmProvider = {
chat: async () => "NO_REPLY",
chatSmall: async () => "x",
};
const agent = new Agent(silentLlm, fakeDb());
const reply = await agent.respond({
conversationId: 1,
userId: 1,
userText: "tell me a joke",
channel: "whatsapp",
});
expect(reply).toBe("");
});
it("does not silence NO_REPLY on non-WhatsApp channels", async () => {
const silentLlm: LlmProvider = {
chat: async () => "NO_REPLY",
chatSmall: async () => "x",
};
const agent = new Agent(silentLlm, fakeDb());
const reply = await agent.respond({
conversationId: 1,
userId: 1,
userText: "tell me a joke",
});
expect(reply).toBe("NO_REPLY");
});
});
describe("isRepeatQuery", () => {
it("normalizes case and whitespace", () => {
expect(normalizeQuery(" 你好 ")).toBe("你好 ");
expect(normalizeQuery("Do You Have Courses?")).toBe(
"do you have courses?",
);
});
it("detects exact repeats", () => {
expect(
isRepeatQuery("Do you have courses?", ["do you have courses?"]),
).toBe(true);
});
it("detects meaningful containment for longer strings", () => {
expect(
isRepeatQuery("do you have courses?", [
"do you have courses? and what is the price",
]),
).toBe(true);
expect(
isRepeatQuery("do you have courses? and what is the price", [
"do you have courses?",
]),
).toBe(true);
});
it("ignores short fuzzy collisions", () => {
expect(isRepeatQuery("hi", ["hi there"])).toBe(false);
});
it("returns false for different questions and empty input", () => {
expect(isRepeatQuery("how much?", ["do you have courses?"])).toBe(false);
expect(isRepeatQuery("", ["anything"])).toBe(false);
expect(isRepeatQuery("hi", [])).toBe(false);
});
});
describe("Agent.updateMemoryAsync", () => {
it("writes lang when it changes", async () => {
const memory = new FakeMemory();
memory.seed(1, "lang", "zh");
const agent = new Agent(new FakeLlm(), fakeDb(), undefined, memory);
await agent.updateMemoryAsync(respondArgs({ userId: 1 }), memory.recallAll(1));
expect(await memory.recall(1, "lang")).toBe("en");
});
it("keeps last_queries at max 5 items and truncates long entries", async () => {
const memory = new FakeMemory();
memory.seed(
1,
"last_queries",
JSON.stringify(["q1", "q2", "q3", "q4", "q5"]),
);
const agent = new Agent(new FakeLlm(), fakeDb(), undefined, memory);
const longText = "x".repeat(300);
await agent.updateMemoryAsync(
respondArgs({ userId: 1, userText: longText }),
await memory.recallAll(1),
);
const stored = JSON.parse((await memory.recall(1, "last_queries")) ?? "[]");
expect(stored).toHaveLength(5);
expect(stored[4]).toHaveLength(200);
expect(stored[0]).toBe("q2");
});
it("skips bot commands", async () => {
const memory = new FakeMemory();
const agent = new Agent(new FakeLlm(), fakeDb(), undefined, memory);
await agent.updateMemoryAsync(
respondArgs({ userId: 1, userText: "/start" }),
memory.recallAll(1),
);
expect(await memory.recall(1, "last_queries")).toBeUndefined();
expect(await memory.recall(1, "lang")).toBeUndefined();
});
it("triggers summarizer on a new SKU for a fresh user", async () => {
const memory = new FakeMemory();
const summarizer = new FakeSummarizer();
const agent = new Agent(
new FakeLlm(),
fakeDb(),
undefined,
memory,
summarizer,
);
await agent.updateMemoryAsync(
respondArgs({ userId: 1, userText: "how about CDD01?" }),
memory.recallAll(1),
);
expect(summarizer.calls).toHaveLength(1);
expect(summarizer.calls[0].currentTurn).toBe("how about CDD01?");
expect(await memory.recall(1, MEMORY_KEY_SUMMARY)).toBe("remembered summary");
});
it("does not summarise for plain chit-chat once lang is settled", async () => {
const memory = new FakeMemory();
memory.seed(1, "lang", "en");
const summarizer = new FakeSummarizer();
const agent = new Agent(
new FakeLlm(),
fakeDb(),
undefined,
memory,
summarizer,
);
await agent.updateMemoryAsync(
respondArgs({ userId: 1, userText: "hi" }),
await memory.recallAll(1),
);
expect(summarizer.calls).toHaveLength(0);
});
it("summarizer failure keeps the old summary and never throws", async () => {
const memory = new FakeMemory();
memory.seed(1, "lang", "en");
memory.seed(1, MEMORY_KEY_SUMMARY, "old summary");
const summarizer = new FakeSummarizer();
summarizer.fail = true;
const agent = new Agent(
new FakeLlm(),
fakeDb(),
undefined,
memory,
summarizer,
);
await expect(
agent.updateMemoryAsync(
respondArgs({ userId: 1, userText: "我叫小明,想买CDD01" }),
await memory.recallAll(1),
),
).resolves.toBeUndefined();
expect(await memory.recall(1, MEMORY_KEY_SUMMARY)).toBe("old summary");
});
});