@guava-ai/guava-sdk 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/examples/help-desk.js +22 -8
- package/dist/examples/help-desk.js.map +1 -1
- package/dist/examples/property-insurance.js +14 -3
- package/dist/examples/property-insurance.js.map +1 -1
- package/dist/examples/restaurant-waitlist.js +9 -1
- package/dist/examples/restaurant-waitlist.js.map +1 -1
- package/dist/examples/scheduling-outbound.js +2 -4
- package/dist/examples/scheduling-outbound.js.map +1 -1
- package/dist/src/action-item.d.ts +4 -4
- package/dist/src/action-item.js +1 -1
- package/dist/src/action-item.js.map +1 -1
- package/dist/src/agent.d.ts +21 -7
- package/dist/src/agent.js +275 -123
- package/dist/src/agent.js.map +1 -1
- package/dist/src/call.d.ts +9 -1
- package/dist/src/call.js +24 -1
- package/dist/src/call.js.map +1 -1
- package/dist/src/commands.d.ts +37 -9
- package/dist/src/commands.js +13 -3
- package/dist/src/commands.js.map +1 -1
- package/dist/src/events.d.ts +31 -1
- package/dist/src/events.js +11 -2
- package/dist/src/events.js.map +1 -1
- package/dist/src/health.d.ts +35 -0
- package/dist/src/health.js +99 -0
- package/dist/src/health.js.map +1 -0
- package/dist/src/helpers/index.d.ts +1 -0
- package/dist/src/helpers/index.js +9 -0
- package/dist/src/helpers/index.js.map +1 -0
- package/dist/src/helpers/llm.d.ts +33 -1
- package/dist/src/helpers/llm.js +220 -0
- package/dist/src/helpers/llm.js.map +1 -1
- package/dist/src/helpers/server-rag.d.ts +25 -0
- package/dist/src/helpers/server-rag.js +119 -0
- package/dist/src/helpers/server-rag.js.map +1 -0
- package/dist/src/socket/protocol.d.ts +4 -4
- package/dist/src/testing/mocks.d.ts +6 -0
- package/dist/src/testing/mocks.js +12 -0
- package/dist/src/testing/mocks.js.map +1 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/examples/help-desk.ts +21 -12
- package/examples/property-insurance.ts +15 -3
- package/examples/restaurant-waitlist.ts +10 -1
- package/examples/scheduling-outbound.ts +2 -4
- package/package.json +5 -1
- package/src/action-item.ts +1 -1
- package/src/agent.ts +271 -129
- package/src/call.ts +37 -1
- package/src/commands.ts +16 -2
- package/src/events.ts +12 -1
- package/src/health.ts +120 -0
- package/src/helpers/index.ts +2 -0
- package/src/helpers/llm.ts +178 -1
- package/src/helpers/server-rag.ts +132 -0
- package/src/testing/mocks.ts +17 -0
- package/src/version.ts +1 -1
package/src/call.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
TransferCommand,
|
|
10
10
|
ReadScriptCommand,
|
|
11
11
|
RetryTaskCommand,
|
|
12
|
+
SetAgentDTMFCommand,
|
|
12
13
|
} from "./commands.ts";
|
|
13
14
|
import type * as z from "zod";
|
|
14
15
|
import type {
|
|
@@ -20,6 +21,7 @@ import type {
|
|
|
20
21
|
} from "./action-item.ts";
|
|
21
22
|
import { Say } from "./action-item.ts";
|
|
22
23
|
import { telemetryClient } from "./telemetry.ts";
|
|
24
|
+
import type { CallInfo } from "./socket/call-info.ts";
|
|
23
25
|
|
|
24
26
|
export type TaskObjective =
|
|
25
27
|
| { objective: string }
|
|
@@ -33,6 +35,8 @@ export type ReachPersonOutcome = {
|
|
|
33
35
|
|
|
34
36
|
@telemetryClient.trackClass()
|
|
35
37
|
export class Call {
|
|
38
|
+
private _callId: string;
|
|
39
|
+
private _callInfo: CallInfo;
|
|
36
40
|
protected _commandQueue: Command[] = [];
|
|
37
41
|
private _variables: Record<string, any> = {};
|
|
38
42
|
protected logger: Logger;
|
|
@@ -42,8 +46,17 @@ export class Call {
|
|
|
42
46
|
// input (mutating it) (i.e. _drain should use Array.splice)
|
|
43
47
|
private _drain?: (_: Command[]) => Promise<void>;
|
|
44
48
|
_fieldValues: Record<string, unknown> = {};
|
|
49
|
+
_fieldKeysByTaskId: Record<string, string[]> = {};
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
callId: string,
|
|
53
|
+
callInfo: CallInfo,
|
|
54
|
+
variables: Record<string, any> = {},
|
|
55
|
+
logger: Logger = getDefaultLogger(),
|
|
56
|
+
) {
|
|
57
|
+
this._callId = callId;
|
|
58
|
+
this._callInfo = callInfo;
|
|
45
59
|
|
|
46
|
-
constructor(variables: Record<string, any> = {}, logger: Logger = getDefaultLogger()) {
|
|
47
60
|
// Set initial variables.
|
|
48
61
|
this._variables = { ...variables };
|
|
49
62
|
|
|
@@ -51,6 +64,14 @@ export class Call {
|
|
|
51
64
|
this.logger = logger;
|
|
52
65
|
}
|
|
53
66
|
|
|
67
|
+
get id(): string {
|
|
68
|
+
return this._callId;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get callInfo(): CallInfo {
|
|
72
|
+
return this._callInfo;
|
|
73
|
+
}
|
|
74
|
+
|
|
54
75
|
/**
|
|
55
76
|
* @description Supply a function used to consume commands from the internal command queue.
|
|
56
77
|
*
|
|
@@ -75,6 +96,10 @@ export class Call {
|
|
|
75
96
|
}
|
|
76
97
|
}
|
|
77
98
|
|
|
99
|
+
hasField(key: string): boolean {
|
|
100
|
+
return key in this._fieldValues;
|
|
101
|
+
}
|
|
102
|
+
|
|
78
103
|
async sendCommand<C extends Command, Schema extends z.ZodType<C>>(
|
|
79
104
|
schema: Schema,
|
|
80
105
|
data: z.input<Schema>,
|
|
@@ -84,6 +109,13 @@ export class Call {
|
|
|
84
109
|
await this.flush();
|
|
85
110
|
}
|
|
86
111
|
|
|
112
|
+
async setAgentDtmf(enabled: boolean) {
|
|
113
|
+
await this.sendCommand(SetAgentDTMFCommand, {
|
|
114
|
+
command_type: "set-agent-dtmf",
|
|
115
|
+
enabled,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
87
119
|
async setLanguageMode(args: { primary?: Language; secondary?: Language[] }) {
|
|
88
120
|
await this.sendCommand(SetLanguageModeCommand, {
|
|
89
121
|
command_type: "set-language-mode",
|
|
@@ -128,6 +160,7 @@ export class Call {
|
|
|
128
160
|
throw new Error("At least one of ['objective', 'checklist'] must be provided.");
|
|
129
161
|
}
|
|
130
162
|
|
|
163
|
+
const fieldKeys: string[] = [];
|
|
131
164
|
const action_items = checklist.map((item): ActionItem => {
|
|
132
165
|
if (typeof item === "string") {
|
|
133
166
|
return { item_type: "todo", description: item } satisfies TodoItem;
|
|
@@ -138,12 +171,15 @@ export class Call {
|
|
|
138
171
|
"choiceGenerator is not compatible with the Agent / Call API. Use searchable=true and register a handler.",
|
|
139
172
|
);
|
|
140
173
|
}
|
|
174
|
+
fieldKeys.push(item.key);
|
|
141
175
|
const { choiceGenerator: _, ...fieldData } = item;
|
|
142
176
|
return { ...fieldData, is_search_field: item.searchable } satisfies SerializableFieldItem;
|
|
143
177
|
}
|
|
144
178
|
return item;
|
|
145
179
|
});
|
|
146
180
|
|
|
181
|
+
this._fieldKeysByTaskId[taskId] = fieldKeys;
|
|
182
|
+
|
|
147
183
|
await this.sendCommand(SetTaskCommand, {
|
|
148
184
|
command_type: "set-task",
|
|
149
185
|
task_id: taskId,
|
package/src/commands.ts
CHANGED
|
@@ -94,14 +94,21 @@ export const RegisteredHooksCommand = z.strictObject({
|
|
|
94
94
|
has_on_question: z.boolean(),
|
|
95
95
|
has_on_intent: z.boolean(),
|
|
96
96
|
has_on_action_requested: z.boolean().optional().default(false),
|
|
97
|
+
has_on_escalate: z.boolean().optional().default(false),
|
|
98
|
+
accept_dtmf_for_numbers: z.boolean().default(true),
|
|
97
99
|
});
|
|
98
100
|
export type RegisteredHooksCommand = z.input<typeof RegisteredHooksCommand>;
|
|
99
101
|
|
|
102
|
+
export const ActionCandidate = z.strictObject({
|
|
103
|
+
key: z.string(),
|
|
104
|
+
description: z.string().default(""),
|
|
105
|
+
});
|
|
106
|
+
export type ActionCandidate = z.input<typeof ActionCandidate>;
|
|
107
|
+
|
|
100
108
|
export const ActionSuggestionCommand = z.strictObject({
|
|
101
109
|
command_type: z.literal("action-suggestion"),
|
|
102
110
|
intent_id: z.string(),
|
|
103
|
-
|
|
104
|
-
action_description: z.string().default(""),
|
|
111
|
+
actions: z.array(ActionCandidate),
|
|
105
112
|
});
|
|
106
113
|
export type ActionSuggestionCommand = z.input<typeof ActionSuggestionCommand>;
|
|
107
114
|
|
|
@@ -120,6 +127,12 @@ export const RetryTaskCommand = z.strictObject({
|
|
|
120
127
|
});
|
|
121
128
|
export type RetryTaskCommand = z.input<typeof RetryTaskCommand>;
|
|
122
129
|
|
|
130
|
+
export const SetAgentDTMFCommand = z.strictObject({
|
|
131
|
+
command_type: z.literal("set-agent-dtmf"),
|
|
132
|
+
enabled: z.boolean(),
|
|
133
|
+
});
|
|
134
|
+
export type SetAgentDTMFCommand = z.input<typeof SetAgentDTMFCommand>;
|
|
135
|
+
|
|
123
136
|
export const AnyCommand = z.union([
|
|
124
137
|
StartOutboundCallCommand,
|
|
125
138
|
ListenInboundCommand,
|
|
@@ -136,6 +149,7 @@ export const AnyCommand = z.union([
|
|
|
136
149
|
ChoiceResultCommand,
|
|
137
150
|
ActionSuggestionCommand,
|
|
138
151
|
RetryTaskCommand,
|
|
152
|
+
SetAgentDTMFCommand,
|
|
139
153
|
]);
|
|
140
154
|
export type Command = z.input<typeof AnyCommand>;
|
|
141
155
|
|
package/src/events.ts
CHANGED
|
@@ -14,12 +14,14 @@ export const InboundCallEvent = z.object({
|
|
|
14
14
|
export type InboundCallEvent = z.infer<typeof InboundCallEvent>;
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* @description The caller has said something.
|
|
17
|
+
* @description The caller has said something. For utterances with the same id,
|
|
18
|
+
* all but the latest is out of date.
|
|
18
19
|
*/
|
|
19
20
|
export const CallerSpeechEvent = z.object({
|
|
20
21
|
event_type: z.literal("caller-speech"),
|
|
21
22
|
|
|
22
23
|
utterance: z.string(),
|
|
24
|
+
utterance_id: z.string().optional(),
|
|
23
25
|
});
|
|
24
26
|
export type CallerSpeechEvent = z.infer<typeof CallerSpeechEvent>;
|
|
25
27
|
|
|
@@ -96,6 +98,7 @@ export const BotSessionEnded = z.object({
|
|
|
96
98
|
"bot-transfer",
|
|
97
99
|
"voicemail",
|
|
98
100
|
]),
|
|
101
|
+
dnc: z.boolean().default(false),
|
|
99
102
|
});
|
|
100
103
|
export type BotSessionEnded = z.infer<typeof BotSessionEnded>;
|
|
101
104
|
|
|
@@ -144,9 +147,16 @@ export type DTMFDigit =
|
|
|
144
147
|
export const DTMFPressedEvent = z.object({
|
|
145
148
|
event_type: z.literal("dtmf"),
|
|
146
149
|
digit: z.enum(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#", "A", "B", "C", "D"]),
|
|
150
|
+
recent_digits: z.string().default(""),
|
|
147
151
|
});
|
|
148
152
|
export type DTMFPressedEvent = z.infer<typeof DTMFPressedEvent>;
|
|
149
153
|
|
|
154
|
+
export const EscalateEvent = z.object({
|
|
155
|
+
event_type: z.literal("escalate"),
|
|
156
|
+
requested_by: z.enum(["human", "agent"]).default("human"),
|
|
157
|
+
});
|
|
158
|
+
export type EscalateEvent = z.infer<typeof EscalateEvent>;
|
|
159
|
+
|
|
150
160
|
export const GuavaEvent = z.discriminatedUnion("event_type", [
|
|
151
161
|
SessionStartedEvent,
|
|
152
162
|
InboundCallEvent,
|
|
@@ -164,6 +174,7 @@ export const GuavaEvent = z.discriminatedUnion("event_type", [
|
|
|
164
174
|
ChoiceQueryEvent,
|
|
165
175
|
ActionRequestEvent,
|
|
166
176
|
ExecuteActionEvent,
|
|
177
|
+
EscalateEvent,
|
|
167
178
|
DTMFPressedEvent,
|
|
168
179
|
]);
|
|
169
180
|
export type GuavaEvent = z.infer<typeof GuavaEvent>;
|
package/src/health.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { type Server, createServer } from "node:http";
|
|
2
|
+
import { getDefaultLogger, type Logger } from "./logging.ts";
|
|
3
|
+
|
|
4
|
+
export type HealthState = "starting" | "live" | "stopped";
|
|
5
|
+
|
|
6
|
+
export class HealthContext {
|
|
7
|
+
private _state: HealthState = "starting";
|
|
8
|
+
|
|
9
|
+
setState(newState: HealthState): void {
|
|
10
|
+
this._state = newState;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
ready(): void {
|
|
14
|
+
this.setState("live");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
stopped(): void {
|
|
18
|
+
this.setState("stopped");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
isLive(): boolean {
|
|
22
|
+
return this._state === "live";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class MultiHealthContext {
|
|
27
|
+
private _ctxs: HealthContext[] = [];
|
|
28
|
+
|
|
29
|
+
createCtx(): HealthContext {
|
|
30
|
+
const ctx = new HealthContext();
|
|
31
|
+
this._ctxs.push(ctx);
|
|
32
|
+
return ctx;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
isLive(): boolean {
|
|
36
|
+
return this._ctxs.length > 0 && this._ctxs.every((ctx) => ctx.isLive());
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface HealthServerOptions {
|
|
41
|
+
host?: string;
|
|
42
|
+
port?: number;
|
|
43
|
+
logger?: Logger;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class HealthServer implements AsyncDisposable {
|
|
47
|
+
private readonly _server: Server;
|
|
48
|
+
private readonly _logger: Logger;
|
|
49
|
+
private _stopping: Promise<void> | null = null;
|
|
50
|
+
|
|
51
|
+
private constructor(server: Server, logger: Logger) {
|
|
52
|
+
this._server = server;
|
|
53
|
+
this._logger = logger;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static async start(
|
|
57
|
+
healthCtx: HealthContext | MultiHealthContext,
|
|
58
|
+
options: HealthServerOptions = {},
|
|
59
|
+
): Promise<HealthServer> {
|
|
60
|
+
const host = options.host ?? "0.0.0.0";
|
|
61
|
+
const port = options.port ?? 4828;
|
|
62
|
+
const logger = options.logger ?? getDefaultLogger();
|
|
63
|
+
|
|
64
|
+
const server = createServer((req, res) => {
|
|
65
|
+
if (req.method === "GET" && req.url === "/live") {
|
|
66
|
+
res.statusCode = healthCtx.isLive() ? 200 : 503;
|
|
67
|
+
} else {
|
|
68
|
+
res.statusCode = 404;
|
|
69
|
+
}
|
|
70
|
+
res.end();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await new Promise<void>((resolve, reject) => {
|
|
74
|
+
server.once("error", reject);
|
|
75
|
+
server.listen(port, host, () => {
|
|
76
|
+
server.off("error", reject);
|
|
77
|
+
resolve();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
logger.info("Health check server listening on http://%s:%d/live", host, port);
|
|
82
|
+
return new HealthServer(server, logger);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
stop(): Promise<void> {
|
|
86
|
+
if (this._stopping) return this._stopping;
|
|
87
|
+
this._logger.info("Health check server shutting down.");
|
|
88
|
+
this._stopping = new Promise<void>((resolve, reject) => {
|
|
89
|
+
this._server.closeAllConnections();
|
|
90
|
+
this._server.close((err) => (err ? reject(err) : resolve()));
|
|
91
|
+
});
|
|
92
|
+
return this._stopping;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async [Symbol.asyncDispose](): Promise<void> {
|
|
96
|
+
await this.stop();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const NOOP_DISPOSABLE: AsyncDisposable = {
|
|
101
|
+
async [Symbol.asyncDispose]() {},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
function isEnabled(): boolean {
|
|
105
|
+
const value = (process.env.GUAVA_HEALTH_SERVER ?? "false").toLowerCase().trim();
|
|
106
|
+
return value === "yes" || value === "true" || value === "on";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Returns an AsyncDisposable that, when the GUAVA_HEALTH_SERVER env var is
|
|
111
|
+
* truthy ("yes", "true", "on"), starts an HTTP health check server exposing
|
|
112
|
+
* `/live` on port 4828. Otherwise returns a no-op disposable. Intended for use
|
|
113
|
+
* with `await using`.
|
|
114
|
+
*/
|
|
115
|
+
export async function getHealthServer(
|
|
116
|
+
healthCtx: HealthContext | MultiHealthContext,
|
|
117
|
+
): Promise<AsyncDisposable> {
|
|
118
|
+
if (!isEnabled()) return NOOP_DISPOSABLE;
|
|
119
|
+
return HealthServer.start(healthCtx);
|
|
120
|
+
}
|
package/src/helpers/llm.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SuggestedAction } from "../agent.ts";
|
|
2
|
+
import { Client } from "../client.ts";
|
|
3
|
+
import { telemetryClient } from "../telemetry.ts";
|
|
2
4
|
import { fetchOrThrow } from "../utils.ts";
|
|
5
|
+
import { ServerRAG } from "./server-rag.ts";
|
|
3
6
|
|
|
4
7
|
export async function _generate(
|
|
5
8
|
client: Client,
|
|
@@ -14,7 +17,181 @@ export async function _generate(
|
|
|
14
17
|
method: "POST",
|
|
15
18
|
headers: { ...(await client.headers()), "Content-Type": "application/json" },
|
|
16
19
|
body: JSON.stringify(body),
|
|
20
|
+
signal: AbortSignal.timeout(60_000),
|
|
17
21
|
});
|
|
18
22
|
|
|
19
23
|
return ((await response.json()) as { text: string }).text;
|
|
20
24
|
}
|
|
25
|
+
|
|
26
|
+
@telemetryClient.trackClass()
|
|
27
|
+
export class DocumentQA {
|
|
28
|
+
private readonly _rag: ServerRAG;
|
|
29
|
+
private readonly _instructions: string | undefined;
|
|
30
|
+
private readonly _initialized: Promise<void>;
|
|
31
|
+
|
|
32
|
+
constructor({
|
|
33
|
+
documents,
|
|
34
|
+
ids,
|
|
35
|
+
instructions,
|
|
36
|
+
namespace,
|
|
37
|
+
}: {
|
|
38
|
+
documents?: string | string[];
|
|
39
|
+
ids?: string[];
|
|
40
|
+
instructions?: string;
|
|
41
|
+
namespace?: string;
|
|
42
|
+
} = {}) {
|
|
43
|
+
this._rag = new ServerRAG(new Client(), { namespace });
|
|
44
|
+
this._instructions = instructions;
|
|
45
|
+
|
|
46
|
+
if (documents !== undefined) {
|
|
47
|
+
const docs = typeof documents === "string" ? [documents] : documents;
|
|
48
|
+
this._initialized = this._rag.reconcile(docs, ids);
|
|
49
|
+
} else {
|
|
50
|
+
this._initialized = Promise.resolve();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async upsertDocument(key: string, text: string): Promise<void> {
|
|
55
|
+
await this._initialized;
|
|
56
|
+
await this._rag.upsertDocument(key, text);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async addDocument(text: string): Promise<void> {
|
|
60
|
+
await this._initialized;
|
|
61
|
+
await this._rag.addDocument(text);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async deleteDocument(key: string): Promise<void> {
|
|
65
|
+
await this._initialized;
|
|
66
|
+
await this._rag.removeDocument(key);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async clear(): Promise<void> {
|
|
70
|
+
await this._initialized;
|
|
71
|
+
await this._rag.clear();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async ask(question: string): Promise<string> {
|
|
75
|
+
await this._initialized;
|
|
76
|
+
const trackedKeys = this._rag.trackedKeys;
|
|
77
|
+
return this._rag.ask(question, {
|
|
78
|
+
documentKeys: trackedKeys.size > 0 ? [...trackedKeys] : undefined,
|
|
79
|
+
instructions: this._instructions,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const _filterSchema = {
|
|
85
|
+
type: "object",
|
|
86
|
+
properties: {
|
|
87
|
+
matching_appointments: { type: "array", items: { type: "string" } },
|
|
88
|
+
other_appointments: { type: "array", items: { type: "string" } },
|
|
89
|
+
},
|
|
90
|
+
required: ["matching_appointments", "other_appointments"],
|
|
91
|
+
additionalProperties: false,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
@telemetryClient.trackClass()
|
|
95
|
+
export class DatetimeFilter {
|
|
96
|
+
private readonly _client: Client;
|
|
97
|
+
private readonly _slotsStr: string;
|
|
98
|
+
|
|
99
|
+
constructor(sourceList: string[]) {
|
|
100
|
+
this._client = new Client();
|
|
101
|
+
this._slotsStr = sourceList.join("\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async filter(
|
|
105
|
+
query: string,
|
|
106
|
+
{ maxResults = 5 }: { maxResults?: number } = {},
|
|
107
|
+
): Promise<[string[], string[]]> {
|
|
108
|
+
const today = new Date().toLocaleDateString("en-US", {
|
|
109
|
+
month: "long",
|
|
110
|
+
day: "numeric",
|
|
111
|
+
year: "numeric",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const prompt = `Return datetime slots from the list that match the query.
|
|
115
|
+
If none match, return close alternatives in other_appointments instead.
|
|
116
|
+
Never return datetimes that are not in the list.
|
|
117
|
+
|
|
118
|
+
Query: <query>${query}</query>
|
|
119
|
+
Today's Date: ${today}
|
|
120
|
+
Available slots:
|
|
121
|
+
${this._slotsStr}
|
|
122
|
+
|
|
123
|
+
Return at most ${maxResults} items per list.`;
|
|
124
|
+
|
|
125
|
+
const text = await _generate(this._client, prompt, _filterSchema);
|
|
126
|
+
const result = JSON.parse(text) as {
|
|
127
|
+
matching_appointments: string[];
|
|
128
|
+
other_appointments: string[];
|
|
129
|
+
};
|
|
130
|
+
return [
|
|
131
|
+
result.matching_appointments.slice(0, maxResults),
|
|
132
|
+
result.other_appointments.slice(0, maxResults),
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@telemetryClient.trackClass()
|
|
138
|
+
export class IntentRecognizer {
|
|
139
|
+
private readonly _client: Client;
|
|
140
|
+
private readonly _intentChoices: string[] | Record<string, string>;
|
|
141
|
+
private readonly _schema: object;
|
|
142
|
+
|
|
143
|
+
constructor(intentChoices: string[] | Record<string, string>) {
|
|
144
|
+
this._client = new Client();
|
|
145
|
+
this._intentChoices = intentChoices;
|
|
146
|
+
const choiceList = Array.isArray(intentChoices) ? intentChoices : Object.keys(intentChoices);
|
|
147
|
+
this._schema = {
|
|
148
|
+
type: "object",
|
|
149
|
+
title: "ChoiceModel",
|
|
150
|
+
properties: {
|
|
151
|
+
possible_matches: {
|
|
152
|
+
type: "array",
|
|
153
|
+
items: { type: "string", enum: choiceList },
|
|
154
|
+
description:
|
|
155
|
+
"Choices that could match the caller's intent, ordered by likelihood. Include all plausible matches.",
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
required: ["possible_matches"],
|
|
159
|
+
additionalProperties: false,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async classify(intent: string): Promise<SuggestedAction[] | null> {
|
|
164
|
+
const choiceList = Array.isArray(this._intentChoices)
|
|
165
|
+
? this._intentChoices
|
|
166
|
+
: Object.keys(this._intentChoices);
|
|
167
|
+
|
|
168
|
+
let prompt = `Classify the intent below into the most appropriate choice(s) from the list.
|
|
169
|
+
|
|
170
|
+
Intent: <intent>${intent}</intent>
|
|
171
|
+
Available Choices: ${JSON.stringify(choiceList)}
|
|
172
|
+
|
|
173
|
+
Rules:
|
|
174
|
+
- Default to returning a single choice — the one that best matches the intent.
|
|
175
|
+
- Only return additional choices when the intent is genuinely ambiguous: a reasonable person reading it would be unable to decide which category it belongs to. Thematic overlap or partial relevance is NOT enough — do not include weakly or tangentially related choices.
|
|
176
|
+
- Order matches by likelihood (most likely first).
|
|
177
|
+
- If no choice plausibly matches, return an empty list.`;
|
|
178
|
+
|
|
179
|
+
if (!Array.isArray(this._intentChoices)) {
|
|
180
|
+
const descriptionString = Object.entries(this._intentChoices)
|
|
181
|
+
.map(([key, val]) => `${key}: ${val}`)
|
|
182
|
+
.join("\n ");
|
|
183
|
+
prompt += `\n\nDetailed descriptions of each choice:\n ${descriptionString}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const text = await _generate(this._client, prompt, this._schema);
|
|
187
|
+
const keys = (JSON.parse(text) as { possible_matches: string[] }).possible_matches;
|
|
188
|
+
|
|
189
|
+
if (!keys.length) return null;
|
|
190
|
+
|
|
191
|
+
if (!Array.isArray(this._intentChoices)) {
|
|
192
|
+
const choices = this._intentChoices;
|
|
193
|
+
return keys.map((k) => ({ key: k, description: choices[k] }));
|
|
194
|
+
}
|
|
195
|
+
return keys.map((k) => ({ key: k }));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { Client } from "../client.ts";
|
|
3
|
+
import { fetchOrThrow } from "../utils.ts";
|
|
4
|
+
|
|
5
|
+
export function _contentKey(text: string): string {
|
|
6
|
+
return createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function _prefixedKey(namespace: string | undefined, key: string): string {
|
|
10
|
+
return namespace ? `${namespace}.${key}` : key;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class ServerRAG {
|
|
14
|
+
private readonly _client: Client;
|
|
15
|
+
private readonly _namespace: string | undefined;
|
|
16
|
+
readonly trackedKeys = new Set<string>();
|
|
17
|
+
|
|
18
|
+
constructor(client: Client, { namespace }: { namespace?: string } = {}) {
|
|
19
|
+
this._client = client;
|
|
20
|
+
this._namespace = namespace;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async uploadDocument(key: string, text: string): Promise<void> {
|
|
24
|
+
const url = new URL("v1/rag/documents", this._client.getHttpBase());
|
|
25
|
+
await fetchOrThrow(url, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: { ...(await this._client.headers()), "Content-Type": "application/json" },
|
|
28
|
+
body: JSON.stringify({ key, text }),
|
|
29
|
+
signal: AbortSignal.timeout(60_000),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async deleteDocument(key: string): Promise<void> {
|
|
34
|
+
const url = new URL(`v1/rag/documents/${key}`, this._client.getHttpBase());
|
|
35
|
+
await fetchOrThrow(url, {
|
|
36
|
+
method: "DELETE",
|
|
37
|
+
headers: await this._client.headers(),
|
|
38
|
+
signal: AbortSignal.timeout(30_000),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async listDocuments(): Promise<{ key: string }[]> {
|
|
43
|
+
const url = new URL("v1/rag/documents", this._client.getHttpBase());
|
|
44
|
+
const response = await fetchOrThrow(url, {
|
|
45
|
+
headers: await this._client.headers(),
|
|
46
|
+
signal: AbortSignal.timeout(30_000),
|
|
47
|
+
});
|
|
48
|
+
return (await response.json()) as { key: string }[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async ask(
|
|
52
|
+
question: string,
|
|
53
|
+
{ documentKeys, instructions }: { documentKeys?: string[]; instructions?: string } = {},
|
|
54
|
+
): Promise<string> {
|
|
55
|
+
const url = new URL("v1/rag/ask", this._client.getHttpBase());
|
|
56
|
+
const payload: Record<string, unknown> = { question };
|
|
57
|
+
if (documentKeys !== undefined) payload.document_keys = documentKeys;
|
|
58
|
+
if (instructions !== undefined) payload.instructions = instructions;
|
|
59
|
+
|
|
60
|
+
const response = await fetchOrThrow(url, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: { ...(await this._client.headers()), "Content-Type": "application/json" },
|
|
63
|
+
body: JSON.stringify(payload),
|
|
64
|
+
signal: AbortSignal.timeout(120_000),
|
|
65
|
+
});
|
|
66
|
+
const result = (await response.json()) as { answer: string; warning?: string };
|
|
67
|
+
if (result.warning) console.warn(`Guava RAG: ${result.warning}`);
|
|
68
|
+
return result.answer;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async reconcile(documents: string[], ids?: string[]): Promise<void> {
|
|
72
|
+
if (ids !== undefined && ids.length !== documents.length) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`ids length (${ids.length}) must match documents length (${documents.length})`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const desired = new Map<string, string>();
|
|
79
|
+
if (ids !== undefined) {
|
|
80
|
+
for (let i = 0; i < documents.length; i++) {
|
|
81
|
+
desired.set(_prefixedKey(this._namespace, ids[i]!), documents[i]!);
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
for (const doc of documents) {
|
|
85
|
+
desired.set(_prefixedKey(this._namespace, _contentKey(doc)), doc);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const existing = await this.listDocuments();
|
|
90
|
+
const existingKeys = new Set(existing.map((d) => d.key));
|
|
91
|
+
const scopedKeys = this._namespace
|
|
92
|
+
? new Set([...existingKeys].filter((k) => k.startsWith(`${this._namespace}.`)))
|
|
93
|
+
: existingKeys;
|
|
94
|
+
|
|
95
|
+
for (const [key, doc] of desired) {
|
|
96
|
+
if (ids !== undefined || !existingKeys.has(key)) {
|
|
97
|
+
await this.uploadDocument(key, doc);
|
|
98
|
+
}
|
|
99
|
+
this.trackedKeys.add(key);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const stale = [...scopedKeys].filter((k) => !desired.has(k));
|
|
103
|
+
for (const key of stale) {
|
|
104
|
+
await this.deleteDocument(key);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async upsertDocument(key: string, text: string): Promise<void> {
|
|
109
|
+
const fullKey = _prefixedKey(this._namespace, key);
|
|
110
|
+
await this.uploadDocument(fullKey, text);
|
|
111
|
+
this.trackedKeys.add(fullKey);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async addDocument(text: string): Promise<void> {
|
|
115
|
+
const fullKey = _prefixedKey(this._namespace, _contentKey(text));
|
|
116
|
+
await this.uploadDocument(fullKey, text);
|
|
117
|
+
this.trackedKeys.add(fullKey);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async removeDocument(key: string): Promise<void> {
|
|
121
|
+
const fullKey = _prefixedKey(this._namespace, key);
|
|
122
|
+
await this.deleteDocument(fullKey);
|
|
123
|
+
this.trackedKeys.delete(fullKey);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async clear(): Promise<void> {
|
|
127
|
+
for (const key of this.trackedKeys) {
|
|
128
|
+
await this.deleteDocument(key);
|
|
129
|
+
}
|
|
130
|
+
this.trackedKeys.clear();
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/testing/mocks.ts
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
1
2
|
import { Call } from "../call.ts";
|
|
3
|
+
import type { CallInfo } from "../socket/call-info.ts";
|
|
2
4
|
import type { Command } from "../commands.ts";
|
|
3
5
|
|
|
4
6
|
export class MockCall extends Call {
|
|
7
|
+
constructor(
|
|
8
|
+
args: { callId?: string; callInfo?: CallInfo; variables?: Record<string, any> } = {},
|
|
9
|
+
) {
|
|
10
|
+
super(
|
|
11
|
+
args.callId ?? `mock-${crypto.randomBytes(6).toString("hex")}`,
|
|
12
|
+
args.callInfo ?? {
|
|
13
|
+
call_type: "pstn",
|
|
14
|
+
from_number: null,
|
|
15
|
+
to_number: "+15555555555",
|
|
16
|
+
caller_id: null,
|
|
17
|
+
},
|
|
18
|
+
args.variables ?? {},
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
5
22
|
get commands(): Command[] {
|
|
6
23
|
return this._commandQueue;
|
|
7
24
|
}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = "0.
|
|
1
|
+
export const SDK_VERSION = "0.24.0";
|