Skip to content

Commit 1c5f0b5

Browse files
committed
feat: better AI pipeline
1 parent 336091e commit 1c5f0b5

74 files changed

Lines changed: 3626 additions & 734 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ const loadConversationSeedMock = mock(async () => ({
5959
}));
6060
const loadIntakeContextMock = mock(async () => ({
6161
conversationHistory: [],
62+
decisionMessages: [],
63+
generationEntries: [],
6264
visitorContext: null,
6365
conversationState: {
6466
hasHumanAssignee: false,
@@ -76,6 +78,8 @@ const loadIntakeContextMock = mock(async () => ({
7678
timestamp: "2026-03-04T10:00:00.000Z",
7779
visibility: "public",
7880
},
81+
hasLaterHumanMessage: false,
82+
hasLaterAiMessage: false,
7983
}));
8084
const emitPipelineProcessingCompletedMock = mock(async () => {});
8185
const emitPipelineSeenMock = mock(async () => {});
@@ -220,6 +224,8 @@ describe("runBackgroundPipeline", () => {
220224
});
221225
loadIntakeContextMock.mockResolvedValue({
222226
conversationHistory: [],
227+
decisionMessages: [],
228+
generationEntries: [],
223229
visitorContext: null,
224230
conversationState: {
225231
hasHumanAssignee: false,
@@ -237,6 +243,8 @@ describe("runBackgroundPipeline", () => {
237243
timestamp: "2026-03-04T10:00:00.000Z",
238244
visibility: "public",
239245
},
246+
hasLaterHumanMessage: false,
247+
hasLaterAiMessage: false,
240248
});
241249
runGenerationRuntimeMock.mockResolvedValue({
242250
status: "completed",
@@ -312,7 +320,10 @@ describe("runBackgroundPipeline", () => {
312320
mode: "background_only",
313321
triggerMessageId: "msg-42",
314322
triggerMessageCreatedAt: "2026-03-04T10:05:00.000Z",
323+
generationEntries: [],
315324
allowPublicMessages: false,
325+
hasLaterHumanMessage: false,
326+
hasLaterAiMessage: false,
316327
toolAllowlist: [
317328
"requestKnowledgeClarification",
318329
"updateConversationTitle",

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ async function runBackgroundIntake(ctx: BackgroundPipelineContext): Promise<
120120
conversation: NonNullable<
121121
Awaited<ReturnType<typeof loadConversationSeed>>["conversation"]
122122
>;
123+
decisionMessages: Awaited<
124+
ReturnType<typeof loadIntakeContext>
125+
>["decisionMessages"];
126+
generationEntries: Awaited<
127+
ReturnType<typeof loadIntakeContext>
128+
>["generationEntries"];
123129
conversationHistory: Awaited<
124130
ReturnType<typeof loadIntakeContext>
125131
>["conversationHistory"];
@@ -132,6 +138,12 @@ async function runBackgroundIntake(ctx: BackgroundPipelineContext): Promise<
132138
triggerMessage: Awaited<
133139
ReturnType<typeof loadIntakeContext>
134140
>["triggerMessage"];
141+
hasLaterHumanMessage: Awaited<
142+
ReturnType<typeof loadIntakeContext>
143+
>["hasLaterHumanMessage"];
144+
hasLaterAiMessage: Awaited<
145+
ReturnType<typeof loadIntakeContext>
146+
>["hasLaterAiMessage"];
135147
availableViews: Awaited<ReturnType<typeof listActiveWebsiteViews>>;
136148
}
137149
| {
@@ -212,10 +224,14 @@ async function runBackgroundIntake(ctx: BackgroundPipelineContext): Promise<
212224
toolAllowlist,
213225
modelResolution,
214226
conversation,
227+
decisionMessages: intakeContext.decisionMessages,
228+
generationEntries: intakeContext.generationEntries,
215229
conversationHistory: intakeContext.conversationHistory,
216230
visitorContext: intakeContext.visitorContext,
217231
conversationState: intakeContext.conversationState,
218232
triggerMessage: intakeContext.triggerMessage,
233+
hasLaterHumanMessage: intakeContext.hasLaterHumanMessage,
234+
hasLaterAiMessage: intakeContext.hasLaterAiMessage,
219235
availableViews,
220236
};
221237
}
@@ -307,15 +323,18 @@ export async function runBackgroundPipeline(
307323
mode: "background_only",
308324
aiAgent: intakeResult.aiAgent,
309325
conversation: intakeResult.conversation,
310-
conversationHistory: intakeResult.conversationHistory,
326+
generationEntries: intakeResult.generationEntries,
311327
visitorContext: intakeResult.visitorContext,
312328
conversationState: intakeResult.conversationState,
313329
humanCommand: null,
314330
workflowRunId,
315331
triggerMessageId: ctx.input.sourceMessageId,
332+
triggerMessageText: intakeResult.triggerMessage?.content ?? null,
316333
triggerMessageCreatedAt: ctx.input.sourceMessageCreatedAt,
317334
triggerSenderType: intakeResult.triggerMessage?.senderType,
318335
triggerVisibility: intakeResult.triggerMessage?.visibility,
336+
hasLaterHumanMessage: intakeResult.hasLaterHumanMessage,
337+
hasLaterAiMessage: intakeResult.hasLaterAiMessage,
319338
allowPublicMessages: false,
320339
availableViews: intakeResult.availableViews.map((view) => ({
321340
id: view.id,

apps/api/src/ai-pipeline/primary-pipeline/contracts.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,23 @@ export type ConversationTranscriptEntry =
7272
| RoleAwareMessage
7373
| ConversationToolAction;
7474

75+
export type ConversationContextSegment =
76+
| "before_trigger"
77+
| "trigger"
78+
| "after_trigger";
79+
80+
export type SegmentedConversationMessage = RoleAwareMessage & {
81+
segment: ConversationContextSegment;
82+
};
83+
84+
export type SegmentedConversationToolAction = ConversationToolAction & {
85+
segment: ConversationContextSegment;
86+
};
87+
88+
export type SegmentedConversationEntry =
89+
| SegmentedConversationMessage
90+
| SegmentedConversationToolAction;
91+
7592
export function isConversationToolAction(
7693
entry: ConversationTranscriptEntry
7794
): entry is ConversationToolAction {
@@ -105,12 +122,6 @@ export type ConversationState = {
105122
escalationReason: string | null;
106123
};
107124

108-
export type ContinuationContext = {
109-
previousProcessedMessageId: string;
110-
previousProcessedMessageCreatedAt: string;
111-
latestAiReply: string;
112-
};
113-
114125
export type ModelResolution = {
115126
modelIdOriginal: string;
116127
modelIdResolved: string;

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

Lines changed: 114 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ const runIntakeStepMock = mock((async () => ({
1212
},
1313
conversation: { id: "conv-1" },
1414
conversationHistory: [],
15+
decisionMessages: [],
16+
generationEntries: [],
1517
visitorContext: null,
1618
conversationState: {
1719
hasHumanAssignee: false,
@@ -20,8 +22,9 @@ const runIntakeStepMock = mock((async () => ({
2022
isEscalated: false,
2123
escalationReason: null,
2224
},
23-
continuationContext: null,
2425
triggerMessageText: "Need help",
26+
hasLaterHumanMessage: false,
27+
hasLaterAiMessage: false,
2528
triggerMessage: {
2629
messageId: "msg-1",
2730
senderType: "visitor",
@@ -102,10 +105,13 @@ mock.module("../shared/generation", () => ({
102105
runGenerationRuntime: runGenerationRuntimeMock,
103106
}));
104107

105-
mock.module("../shared/knowledge-gap/immediate-clarification", () => ({
106-
maybeCreateImmediateClarificationFromSearchGap:
107-
maybeCreateImmediateClarificationFromSearchGapMock,
108-
}));
108+
mock.module(
109+
"../shared/knowledge-gap/post-generation-immediate-clarification",
110+
() => ({
111+
maybeCreateImmediateClarificationFromSearchGap:
112+
maybeCreateImmediateClarificationFromSearchGapMock,
113+
})
114+
);
109115

110116
mock.module("../shared/usage", () => ({
111117
trackGenerationUsage: trackGenerationUsageMock,
@@ -164,6 +170,8 @@ describe("runPrimaryPipeline generation error/skip behavior", () => {
164170
},
165171
conversation: { id: "conv-1" },
166172
conversationHistory: [],
173+
decisionMessages: [],
174+
generationEntries: [],
167175
visitorContext: null,
168176
conversationState: {
169177
hasHumanAssignee: false,
@@ -172,8 +180,9 @@ describe("runPrimaryPipeline generation error/skip behavior", () => {
172180
isEscalated: false,
173181
escalationReason: null,
174182
},
175-
continuationContext: null,
176183
triggerMessageText: "Need help",
184+
hasLaterHumanMessage: false,
185+
hasLaterAiMessage: false,
177186
triggerMessage: {
178187
messageId: "msg-1",
179188
senderType: "visitor",
@@ -450,7 +459,7 @@ describe("runPrimaryPipeline generation error/skip behavior", () => {
450459
);
451460
});
452461

453-
it("passes continuation context into generation runtime", async () => {
462+
it("passes split context fields into decision and generation runtime", async () => {
454463
runIntakeStepMock.mockResolvedValueOnce({
455464
status: "ready",
456465
data: {
@@ -462,6 +471,80 @@ describe("runPrimaryPipeline generation error/skip behavior", () => {
462471
},
463472
conversation: { id: "conv-1" },
464473
conversationHistory: [],
474+
decisionMessages: [
475+
{
476+
messageId: "msg-0",
477+
content: "Original issue",
478+
senderType: "visitor",
479+
senderId: "visitor-1",
480+
senderName: null,
481+
timestamp: "2026-03-04T23:59:00.000Z",
482+
visibility: "public",
483+
segment: "before_trigger",
484+
},
485+
{
486+
messageId: "msg-1",
487+
content: "Any update?",
488+
senderType: "visitor",
489+
senderId: "visitor-1",
490+
senderName: null,
491+
timestamp: "2026-03-05T00:00:00.000Z",
492+
visibility: "public",
493+
segment: "trigger",
494+
},
495+
{
496+
messageId: "msg-2",
497+
content: "I already asked for the visitor's order number.",
498+
senderType: "human_agent",
499+
senderId: "user-1",
500+
senderName: "Support Agent",
501+
timestamp: "2026-03-05T00:00:30.000Z",
502+
visibility: "public",
503+
segment: "after_trigger",
504+
},
505+
],
506+
generationEntries: [
507+
{
508+
messageId: "msg-0",
509+
content: "Original issue",
510+
senderType: "visitor",
511+
senderId: "visitor-1",
512+
senderName: null,
513+
timestamp: "2026-03-04T23:59:00.000Z",
514+
visibility: "public",
515+
segment: "before_trigger",
516+
},
517+
{
518+
messageId: "msg-1",
519+
content: "Any update?",
520+
senderType: "visitor",
521+
senderId: "visitor-1",
522+
senderName: null,
523+
timestamp: "2026-03-05T00:00:00.000Z",
524+
visibility: "public",
525+
segment: "trigger",
526+
},
527+
{
528+
kind: "tool",
529+
itemId: "tool-1",
530+
toolName: "searchKnowledgeBase",
531+
content:
532+
'[PRIVATE][TOOL:searchKnowledgeBase] Found 1 source query="order number"',
533+
timestamp: "2026-03-05T00:00:20.000Z",
534+
visibility: "private",
535+
segment: "after_trigger",
536+
},
537+
{
538+
messageId: "msg-2",
539+
content: "I already asked for the visitor's order number.",
540+
senderType: "human_agent",
541+
senderId: "user-1",
542+
senderName: "Support Agent",
543+
timestamp: "2026-03-05T00:00:30.000Z",
544+
visibility: "public",
545+
segment: "after_trigger",
546+
},
547+
],
465548
visitorContext: null,
466549
conversationState: {
467550
hasHumanAssignee: false,
@@ -470,12 +553,9 @@ describe("runPrimaryPipeline generation error/skip behavior", () => {
470553
isEscalated: false,
471554
escalationReason: null,
472555
},
473-
continuationContext: {
474-
previousProcessedMessageId: "msg-0",
475-
previousProcessedMessageCreatedAt: "2026-03-04T23:59:00.000Z",
476-
latestAiReply: "I already asked for the visitor's order number.",
477-
},
478556
triggerMessageText: "Any update?",
557+
hasLaterHumanMessage: true,
558+
hasLaterAiMessage: false,
479559
triggerMessage: {
480560
messageId: "msg-1",
481561
senderType: "visitor",
@@ -491,13 +571,30 @@ describe("runPrimaryPipeline generation error/skip behavior", () => {
491571
});
492572

493573
expect(result.status).toBe("completed");
574+
expect(runDecisionStepMock).toHaveBeenCalledWith(
575+
expect.objectContaining({
576+
input: expect.objectContaining({
577+
decisionMessages: expect.arrayContaining([
578+
expect.objectContaining({
579+
messageId: "msg-2",
580+
segment: "after_trigger",
581+
}),
582+
]),
583+
hasLaterHumanMessage: true,
584+
hasLaterAiMessage: false,
585+
}),
586+
})
587+
);
494588
expect(runGenerationRuntimeMock).toHaveBeenCalledWith(
495589
expect.objectContaining({
496-
continuationContext: {
497-
previousProcessedMessageId: "msg-0",
498-
previousProcessedMessageCreatedAt: "2026-03-04T23:59:00.000Z",
499-
latestAiReply: "I already asked for the visitor's order number.",
500-
},
590+
generationEntries: expect.arrayContaining([
591+
expect.objectContaining({
592+
toolName: "searchKnowledgeBase",
593+
segment: "after_trigger",
594+
}),
595+
]),
596+
hasLaterHumanMessage: true,
597+
hasLaterAiMessage: false,
501598
})
502599
);
503600
});

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
type GenerationRuntimeInput,
1010
runGenerationRuntime,
1111
} from "../shared/generation";
12-
import { maybeCreateImmediateClarificationFromSearchGap } from "../shared/knowledge-gap/immediate-clarification";
12+
import { maybeCreateImmediateClarificationFromSearchGap } from "../shared/knowledge-gap/post-generation-immediate-clarification";
1313
import type { ToolTracePayloadMode } from "../shared/tools/contracts";
1414
import type {
1515
PrimaryPipelineContext,
@@ -47,16 +47,18 @@ function buildGenerationRuntimeInput(params: {
4747
mode: decision.mode,
4848
aiAgent: intake.aiAgent,
4949
conversation: intake.conversation,
50-
conversationHistory: intake.conversationHistory,
50+
generationEntries: intake.generationEntries,
5151
visitorContext: intake.visitorContext,
5252
conversationState: intake.conversationState,
53-
continuationContext: intake.continuationContext,
5453
humanCommand: decision.humanCommand,
5554
workflowRunId: ctx.input.workflowRunId,
5655
triggerMessageId: ctx.input.messageId,
56+
triggerMessageText: intake.triggerMessageText,
5757
triggerMessageCreatedAt: ctx.input.messageCreatedAt,
5858
triggerSenderType: trigger?.senderType,
5959
triggerVisibility: trigger?.visibility,
60+
hasLaterHumanMessage: intake.hasLaterHumanMessage,
61+
hasLaterAiMessage: intake.hasLaterAiMessage,
6062
allowPublicMessages: decision.mode !== "background_only",
6163
stopTyping: typingControls.stopTyping,
6264
debugLogger: params.debugLogger,
@@ -196,7 +198,16 @@ export async function runPrimaryPipeline(
196198
const decisionResult = await measureStage(metrics, "decisionMs", () =>
197199
runDecisionStep({
198200
db: ctx.db,
199-
input: intakeResult.data,
201+
input: {
202+
aiAgent: intakeResult.data.aiAgent,
203+
conversation: intakeResult.data.conversation,
204+
decisionMessages: intakeResult.data.decisionMessages,
205+
conversationState: intakeResult.data.conversationState,
206+
triggerMessage: intakeResult.data.triggerMessage,
207+
triggerMessageText: intakeResult.data.triggerMessageText,
208+
hasLaterHumanMessage: intakeResult.data.hasLaterHumanMessage,
209+
hasLaterAiMessage: intakeResult.data.hasLaterAiMessage,
210+
},
200211
})
201212
);
202213

0 commit comments

Comments
 (0)