Skip to content

Commit c6a5ca9

Browse files
committed
Add dedicated background title review
1 parent 82e88c4 commit c6a5ca9

8 files changed

Lines changed: 1279 additions & 14 deletions

File tree

apps/api/src/ai-pipeline/background-pipeline/index.test.ts

Lines changed: 213 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,30 @@ const runBackgroundKnowledgeGapReviewMock = mock((async () => ({
120120
status: "skipped" as const,
121121
reason: "no_candidate_gap" as const,
122122
})) as (...args: unknown[]) => Promise<any>);
123+
const createModelMock = mock((modelId: string) => modelId);
124+
const generateTextMock = mock((async () => ({
125+
output: {
126+
title: "Visitor asked for help",
127+
confidence: 0.9,
128+
reason: "Fallback title for a generic help request.",
129+
},
130+
})) as (...args: unknown[]) => Promise<any>);
131+
const resolveModelForExecutionMock = mock((modelId: string) => ({
132+
modelIdResolved: modelId,
133+
}));
134+
const loadCurrentConversationMock = mock(async () => ({
135+
id: "conv-1",
136+
visitorId: "visitor-1",
137+
title: null as string | null,
138+
titleSource: null as "ai" | "user" | null,
139+
visitorLanguage: "es",
140+
}));
141+
const realtimeEmitMock = mock(async () => {});
142+
const createTimelineItemMock = mock(async () => {});
143+
const syncConversationVisitorTitleMock = mock(async () => ({
144+
visitorTitle: null,
145+
visitorTitleLanguage: null,
146+
}));
123147

124148
mock.module("../logger", () => ({
125149
logAiPipeline: logAiPipelineMock,
@@ -154,6 +178,36 @@ mock.module("./knowledge-gap-review", () => ({
154178
runBackgroundKnowledgeGapReview: runBackgroundKnowledgeGapReviewMock,
155179
}));
156180

181+
mock.module("@api/lib/ai", () => ({
182+
createModel: createModelMock,
183+
generateText: generateTextMock,
184+
Output: {
185+
object: (params: unknown) => params,
186+
},
187+
}));
188+
189+
mock.module("@api/lib/ai-credits/config", () => ({
190+
resolveModelForExecution: resolveModelForExecutionMock,
191+
}));
192+
193+
mock.module("../shared/actions/load-current-conversation", () => ({
194+
loadCurrentConversation: loadCurrentConversationMock,
195+
}));
196+
197+
mock.module("@api/realtime/emitter", () => ({
198+
realtime: {
199+
emit: realtimeEmitMock,
200+
},
201+
}));
202+
203+
mock.module("@api/utils/timeline-item", () => ({
204+
createTimelineItem: createTimelineItemMock,
205+
}));
206+
207+
mock.module("@api/lib/translation", () => ({
208+
syncConversationVisitorTitle: syncConversationVisitorTitleMock,
209+
}));
210+
157211
mock.module("../shared/events", () => ({
158212
emitPipelineProcessingCompleted: emitPipelineProcessingCompletedMock,
159213
emitPipelineProcessingCompletedSafely: emitPipelineProcessingCompletedMock,
@@ -178,6 +232,34 @@ const baseInput = {
178232
jobId: "job-1",
179233
};
180234

235+
function createDbMock() {
236+
const returningMock = mock(async () => [
237+
{
238+
id: "conv-1",
239+
visitorId: "visitor-1",
240+
titleSource: "ai",
241+
},
242+
]);
243+
const whereMock = mock(() => ({
244+
returning: returningMock,
245+
}));
246+
const setMock = mock(() => ({
247+
where: whereMock,
248+
}));
249+
const updateMock = mock(() => ({
250+
set: setMock,
251+
}));
252+
253+
return {
254+
db: {
255+
update: updateMock,
256+
},
257+
updateMock,
258+
returningMock,
259+
setMock,
260+
};
261+
}
262+
181263
describe("runBackgroundPipeline", () => {
182264
beforeEach(() => {
183265
logAiPipelineMock.mockClear();
@@ -189,6 +271,13 @@ describe("runBackgroundPipeline", () => {
189271
emitPipelineProcessingCompletedMock.mockReset();
190272
runGenerationRuntimeMock.mockReset();
191273
runBackgroundKnowledgeGapReviewMock.mockReset();
274+
createModelMock.mockReset();
275+
generateTextMock.mockReset();
276+
resolveModelForExecutionMock.mockReset();
277+
loadCurrentConversationMock.mockReset();
278+
realtimeEmitMock.mockReset();
279+
createTimelineItemMock.mockReset();
280+
syncConversationVisitorTitleMock.mockReset();
192281

193282
getAiAgentByIdMock.mockResolvedValue({
194283
id: "ai-1",
@@ -281,6 +370,30 @@ describe("runBackgroundPipeline", () => {
281370
status: "skipped",
282371
reason: "no_candidate_gap",
283372
});
373+
createModelMock.mockImplementation((modelId: string) => modelId);
374+
generateTextMock.mockResolvedValue({
375+
output: {
376+
title: "Visitor asked for help",
377+
confidence: 0.9,
378+
reason: "Fallback title for a generic help request.",
379+
},
380+
});
381+
resolveModelForExecutionMock.mockImplementation((modelId: string) => ({
382+
modelIdResolved: modelId,
383+
}));
384+
loadCurrentConversationMock.mockResolvedValue({
385+
id: "conv-1",
386+
visitorId: "visitor-1",
387+
title: null,
388+
titleSource: null,
389+
visitorLanguage: "es",
390+
});
391+
realtimeEmitMock.mockResolvedValue(undefined);
392+
createTimelineItemMock.mockResolvedValue(undefined);
393+
syncConversationVisitorTitleMock.mockResolvedValue({
394+
visitorTitle: null,
395+
visitorTitleLanguage: null,
396+
});
284397
});
285398

286399
it("skips when no background analysis capabilities are enabled", async () => {
@@ -302,6 +415,7 @@ describe("runBackgroundPipeline", () => {
302415
expect(result.status).toBe("skipped");
303416
expect(result.reason).toBe("No background analysis capabilities enabled");
304417
expect(runGenerationRuntimeMock).not.toHaveBeenCalled();
418+
expect(generateTextMock).not.toHaveBeenCalled();
305419
expect(emitPipelineProcessingCompletedMock).toHaveBeenCalledWith(
306420
expect.objectContaining({
307421
status: "skipped",
@@ -338,15 +452,11 @@ describe("runBackgroundPipeline", () => {
338452
allowPublicMessages: false,
339453
hasLaterHumanMessage: false,
340454
hasLaterAiMessage: false,
341-
toolAllowlist: [
342-
"updateConversationTitle",
343-
"updateSentiment",
344-
"setPriority",
345-
"skip",
346-
],
455+
toolAllowlist: ["updateSentiment", "setPriority", "skip"],
347456
availableViews: [],
348457
})
349458
);
459+
expect(generateTextMock).toHaveBeenCalledTimes(1);
350460
expect(emitPipelineProcessingCompletedMock).toHaveBeenCalledWith(
351461
expect.objectContaining({
352462
status: "success",
@@ -425,6 +535,14 @@ describe("runBackgroundPipeline", () => {
425535
});
426536

427537
it("returns skipped when the analysis run makes no metadata mutation", async () => {
538+
getBehaviorSettingsMock.mockReturnValue({
539+
autoGenerateTitle: false,
540+
autoAnalyzeSentiment: true,
541+
canSetPriority: false,
542+
autoCategorize: false,
543+
canCategorize: false,
544+
canRequestKnowledgeClarification: false,
545+
});
428546
runGenerationRuntimeMock.mockResolvedValueOnce({
429547
status: "completed",
430548
action: {
@@ -457,6 +575,95 @@ describe("runBackgroundPipeline", () => {
457575
);
458576
});
459577

578+
it("returns completed when the dedicated title review updates the title even if generic analysis skips", async () => {
579+
generateTextMock.mockResolvedValueOnce({
580+
output: {
581+
title: "Invoice export issue",
582+
confidence: 0.93,
583+
reason: "Clear billing export topic",
584+
},
585+
});
586+
runGenerationRuntimeMock.mockResolvedValueOnce({
587+
status: "completed",
588+
action: {
589+
action: "skip",
590+
reasoning: "No generic metadata update",
591+
confidence: 1,
592+
},
593+
publicMessagesSent: 0,
594+
toolCallsByName: {
595+
skip: 1,
596+
},
597+
mutationToolCallsByName: {},
598+
totalToolCalls: 1,
599+
});
600+
601+
const { runBackgroundPipeline } = await modulePromise;
602+
const { db } = createDbMock();
603+
const result = await runBackgroundPipeline({
604+
db: db as never,
605+
input: baseInput,
606+
});
607+
608+
expect(result.status).toBe("completed");
609+
expect(emitPipelineProcessingCompletedMock).toHaveBeenCalledWith(
610+
expect.objectContaining({
611+
status: "success",
612+
action: "updateConversationTitle",
613+
reason: "Clear billing export topic",
614+
workflowRunId: "wf-1",
615+
audience: "dashboard",
616+
})
617+
);
618+
});
619+
620+
it("schedules dedicated title review when title generation is the only enabled capability", async () => {
621+
getBehaviorSettingsMock.mockReturnValue({
622+
autoGenerateTitle: true,
623+
autoAnalyzeSentiment: false,
624+
canSetPriority: false,
625+
autoCategorize: false,
626+
canCategorize: false,
627+
canRequestKnowledgeClarification: false,
628+
});
629+
generateTextMock.mockResolvedValueOnce({
630+
output: {
631+
title: "Invoice export issue",
632+
confidence: 0.93,
633+
reason: "Clear topic",
634+
},
635+
});
636+
runGenerationRuntimeMock.mockResolvedValueOnce({
637+
status: "completed",
638+
action: {
639+
action: "skip",
640+
reasoning: "No generic metadata update",
641+
confidence: 1,
642+
},
643+
publicMessagesSent: 0,
644+
toolCallsByName: {
645+
skip: 1,
646+
},
647+
mutationToolCallsByName: {},
648+
totalToolCalls: 1,
649+
});
650+
651+
const { runBackgroundPipeline } = await modulePromise;
652+
const { db } = createDbMock();
653+
const result = await runBackgroundPipeline({
654+
db: db as never,
655+
input: baseInput,
656+
});
657+
658+
expect(result.status).toBe("completed");
659+
expect(generateTextMock).toHaveBeenCalledTimes(1);
660+
expect(runGenerationRuntimeMock).toHaveBeenCalledWith(
661+
expect.objectContaining({
662+
toolAllowlist: ["skip"],
663+
})
664+
);
665+
});
666+
460667
it("loads active views and enables categorizeConversation when categorization is available", async () => {
461668
getBehaviorSettingsMock.mockReturnValue({
462669
autoGenerateTitle: false,

apps/api/src/ai-pipeline/background-pipeline/index.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from "../shared/generation";
2020
import { BACKGROUND_ONE_SHOT_TOOL_NAMES } from "../shared/tools/background-one-shot";
2121
import { runBackgroundKnowledgeGapReview } from "./knowledge-gap-review";
22+
import { runBackgroundTitleReview } from "./title-review";
2223

2324
export type BackgroundPipelineInput = {
2425
conversationId: string;
@@ -86,6 +87,7 @@ type BackgroundIntakeReadyData = {
8687
ReturnType<typeof loadIntakeContext>
8788
>["hasLaterAiMessage"];
8889
availableViews: Awaited<ReturnType<typeof listActiveWebsiteViews>>;
90+
titleReviewEnabled: boolean;
8991
};
9092

9193
type BackgroundIntakeResult =
@@ -124,7 +126,7 @@ function getBackgroundToolAllowlist(
124126
const allowlist = BACKGROUND_ANALYSIS_TOOL_IDS.filter((toolId) => {
125127
switch (toolId) {
126128
case "updateConversationTitle":
127-
return settings.autoGenerateTitle;
129+
return false;
128130
case "updateSentiment":
129131
return settings.autoAnalyzeSentiment;
130132
case "setPriority":
@@ -145,6 +147,12 @@ function hasBackgroundAnalysisWork(
145147
return toolAllowlist.some((toolId) => toolId !== "skip");
146148
}
147149

150+
function hasTitleReviewEnabled(
151+
aiAgent: Awaited<ReturnType<typeof getAiAgentById>>
152+
): boolean {
153+
return aiAgent ? getBehaviorSettings(aiAgent).autoGenerateTitle : false;
154+
}
155+
148156
function hasBackgroundMutation(result: GenerationRuntimeResult): boolean {
149157
const mutationCounts =
150158
result.mutationToolCallsByName ?? result.toolCallsByName;
@@ -168,7 +176,8 @@ async function runBackgroundIntake(
168176
}
169177

170178
const toolAllowlist = getBackgroundToolAllowlist(aiAgent);
171-
if (!hasBackgroundAnalysisWork(toolAllowlist)) {
179+
const titleReviewEnabled = hasTitleReviewEnabled(aiAgent);
180+
if (!(titleReviewEnabled || hasBackgroundAnalysisWork(toolAllowlist))) {
172181
return {
173182
status: "skipped",
174183
reason: "No background analysis capabilities enabled",
@@ -239,6 +248,7 @@ async function runBackgroundIntake(
239248
hasLaterHumanMessage: intakeContext.hasLaterHumanMessage,
240249
hasLaterAiMessage: intakeContext.hasLaterAiMessage,
241250
availableViews,
251+
titleReviewEnabled,
242252
};
243253
}
244254

@@ -380,6 +390,25 @@ export async function runBackgroundPipeline(
380390
}
381391

382392
const analysisStartedAt = Date.now();
393+
const titleReviewResult = intakeResult.titleReviewEnabled
394+
? await runBackgroundTitleReview({
395+
db: ctx.db,
396+
aiAgent: intakeResult.aiAgent,
397+
conversation: intakeResult.conversation,
398+
organizationId: ctx.input.organizationId,
399+
websiteId: ctx.input.websiteId,
400+
aiAgentId: ctx.input.aiAgentId,
401+
websiteDefaultLanguage: intakeResult.websiteDefaultLanguage,
402+
visitorLanguage: intakeResult.visitorLanguage,
403+
autoTranslateEnabled: intakeResult.autoTranslateEnabled !== false,
404+
conversationHistory: intakeResult.conversationHistory,
405+
triggerMessage: intakeResult.triggerMessage,
406+
})
407+
: ({
408+
status: "skipped",
409+
reason: "disabled",
410+
} as const);
411+
383412
const generationResult = await runBackgroundAnalysis({
384413
ctx,
385414
intake: intakeResult,
@@ -462,7 +491,8 @@ export async function runBackgroundPipeline(
462491
});
463492
}
464493

465-
if (!hasBackgroundMutation(generationResult)) {
494+
const titleReviewUpdated = titleReviewResult.status === "updated";
495+
if (!(titleReviewUpdated || hasBackgroundMutation(generationResult))) {
466496
logAiPipeline({
467497
area: "background",
468498
event: "skip",
@@ -511,8 +541,12 @@ export async function runBackgroundPipeline(
511541
conversation: intakeResult.conversation,
512542
aiAgentId: intakeResult.aiAgent.id,
513543
status: "success",
514-
action: generationResult.action.action,
515-
reason: generationResult.action.reasoning,
544+
action: titleReviewUpdated
545+
? "updateConversationTitle"
546+
: generationResult.action.action,
547+
reason: titleReviewUpdated
548+
? titleReviewResult.reason
549+
: generationResult.action.reasoning,
516550
});
517551
executionMs = Date.now() - executionStartedAt;
518552
return buildBackgroundPipelineResult({

0 commit comments

Comments
 (0)