Files

150 lines
4.3 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { MemoSummarizer } from "../src/ai/memory/summarizer.js";
import {
shouldSummarize,
parseQueryList,
MEMORY_KEY_SUMMARY,
} from "../src/ai/agent/agent.js";
import type { LlmProvider } from "../src/ai/providers/llm.js";
import { createLogger } from "../src/utils/logger.js";
class FakeLlm implements LlmProvider {
smallCalls: { system: string; user: string }[] = [];
result: string;
fail = false;
constructor(result = "Customer speaks Chinese and is interested in CDD01.") {
this.result = result;
}
async chat(): Promise<string> {
return "main reply";
}
async chatSmall(args: {
system: string;
messages: { role: "user" | "assistant"; content: string }[];
}): Promise<string> {
this.smallCalls.push({
system: args.system,
user: args.messages.map((m) => m.content).join("\n"),
});
if (this.fail) throw new Error("small model down");
return this.result;
}
}
const logger = createLogger("fatal");
describe("MemoSummarizer.extract", () => {
it("returns the plain-text summary from the small model", async () => {
const llm = new FakeLlm("Customer speaks Chinese, wants CDD01.");
const s = new MemoSummarizer(llm, logger);
const out = await s.extract({
priorSummary: "old",
priorQueries: ["what is CDD01?", "price of CDD01"],
currentTurn: "I want to buy CDD01",
});
expect(out).toBe("Customer speaks Chinese, wants CDD01.");
expect(llm.smallCalls).toHaveLength(1);
expect(llm.smallCalls[0].system).toContain("plain-text");
expect(llm.smallCalls[0].user).toContain("Prior summary: old");
expect(llm.smallCalls[0].user).toContain("Latest customer message: I want to buy CDD01");
});
it("returns undefined when the small model throws (caller keeps old value)", async () => {
const llm = new FakeLlm();
llm.fail = true;
const s = new MemoSummarizer(llm, logger);
const out = await s.extract({
priorSummary: "old",
priorQueries: [],
currentTurn: "hi",
});
expect(out).toBeUndefined();
});
it("returns undefined on empty model output", async () => {
const llm = new FakeLlm(" ");
const s = new MemoSummarizer(llm, logger);
const out = await s.extract({
priorQueries: [],
currentTurn: "hi",
});
expect(out).toBeUndefined();
});
});
describe("shouldSummarize", () => {
const base = {
langChanged: false,
userText: "anything",
mem: {},
priorQueries: [] as string[],
turn: 3,
lastSummarizedAt: 0,
};
it("true when the language preference changed", () => {
expect(shouldSummarize({ ...base, langChanged: true })).toBe(true);
});
it("true on a brand-new SKU token", () => {
expect(shouldSummarize({ ...base, userText: "how about CDD01?" })).toBe(true);
});
it("false when the SKU was already seen in prior queries", () => {
expect(
shouldSummarize({
...base,
userText: "how about CDD01 again?",
priorQueries: ["is CDD01 good?"],
}),
).toBe(false);
});
it("false when the SKU is already in the summary", () => {
expect(
shouldSummarize({
...base,
userText: "CDD01 price?",
mem: { [MEMORY_KEY_SUMMARY]: "likes CDD01" },
}),
).toBe(false);
});
it("true on identity keywords", () => {
expect(shouldSummarize({ ...base, userText: "我是 Hoelee,想买" })).toBe(true);
expect(shouldSummarize({ ...base, userText: "I need a refund" })).toBe(true);
});
it("true when >=10 turns since last summary (fallback)", () => {
expect(
shouldSummarize({ ...base, turn: 10, lastSummarizedAt: 0 }),
).toBe(true);
expect(
shouldSummarize({ ...base, turn: 9, lastSummarizedAt: 0 }),
).toBe(false);
});
it("false when nothing matches", () => {
expect(shouldSummarize({ ...base, userText: "thanks" })).toBe(false);
});
});
describe("parseQueryList", () => {
it("parses a valid JSON array", () => {
expect(parseQueryList('["a","b"]')).toEqual(["a", "b"]);
});
it("returns [] for undefined, empty, or malformed", () => {
expect(parseQueryList(undefined)).toEqual([]);
expect(parseQueryList("")).toEqual([]);
expect(parseQueryList("{not json")).toEqual([]);
});
it("drops non-string entries", () => {
expect(parseQueryList('[1,"a",null]')).toEqual(["a"]);
});
});