1import * as dialogflow from "@distilled.cloud/gcp/dialogflow_v3";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { tagRecord } from "../../Tags.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import {
12 createInternalLabels,
13 diffLabels,
14 hasAlchemyLabels,
15 stripInternalLabels,
16 toLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19import {
20 collectionParent,
21 DEFAULT_LOCATION,
22 encodeOwnership,
23 encodeOwnershipLine,
24 expandAgent,
25 fingerprint,
26 hasOwnershipMarker,
27 lastSegment,
28 locationOf,
29 namedAgents,
30 normalizeLocation,
31 ownedByAlchemy,
32 parentOf,
33 parseOwnership,
34 projectOf,
35 sameText,
36 toResourceId,
37 updateMaskOf,
38 retryQuota,
39} from "./internal.ts";
40
41export type IntentTrainingPhrasePart = {
42 /** Parameter id this part annotates. */
43 parameterId?: string;
44 /** Text of this part. */
45 text?: string;
46};
47
48export type IntentTrainingPhrase = {
49 /** Ordered parts that concatenate to the phrase. */
50 parts?: IntentTrainingPhrasePart[];
51 /** Training phrase id. Output only when omitted. */
52 id?: string;
53 /** How many times this phrase is repeated during training. */
54 repeatCount?: number;
55};
56
57export type IntentParameter = {
58 /** Unique parameter id. */
59 id?: string;
60 /** Entity type (`projects/-/locations/-/agents/-/entityTypes/sys.date`). */
61 entityType?: string;
62 /** Redact the parameter in logs. */
63 redact?: boolean;
64 /** Whether the parameter is a list. */
65 isList?: boolean;
66};
67
68export type AgentsIntentProps = {
69 /**
70 * Parent agent resource name
71 * `projects/{project}/locations/{location}/agents/{agent}`.
72 * Immutable — changing it replaces the intent.
73 */
74 agent: string;
75 /**
76 * Intent id (the `{intent}` segment). Server-assigned when omitted.
77 * Immutable — changing it replaces the intent.
78 */
79 intentId?: string;
80 /**
81 * Location used when `agent` is an id rather than a resource name.
82 * Immutable — changing it replaces the intent.
83 * @default "global"
84 */
85 location?: string;
86 /**
87 * Human-readable name unique within the agent. Alchemy stamps
88 * ownership into this field and strips it from attributes.
89 */
90 displayName?: string;
91 /**
92 * User labels. Alchemy ownership labels are merged in automatically.
93 */
94 labels?: Record<string, string>;
95 /** Training phrases. */
96 trainingPhrases?: IntentTrainingPhrase[];
97 /** Intent parameters. */
98 parameters?: IntentParameter[];
99 /**
100 * Matching priority. Higher values match first. `0` or negative
101 * disables the intent.
102 */
103 priority?: number;
104 /**
105 * Whether this is the fallback intent.
106 * @default false
107 */
108 isFallback?: boolean;
109 /**
110 * Human-readable description. Alchemy also stamps ownership here so
111 * `list` / nuke can find the intent if labels are stripped.
112 */
113 description?: string;
114 /** DTMF pattern associated with this intent. */
115 dtmfPattern?: string;
116 /** Language code for training phrases. */
117 languageCode?: string;
118};
119
120export type AgentsIntent = Resource<
121 "GCP.Dialogflow.AgentsIntent",
122 AgentsIntentProps,
123 {
124 /** Full resource name `.../agents/{agent}/intents/{intent}`. */
125 name: string;
126 /** Intent id (last path segment). */
127 intentId: string;
128 /** Parent agent resource name. */
129 agent: string;
130 /** Location id. */
131 location: string;
132 /** Project id. */
133 project: string;
134 /** User display name with the Alchemy ownership prefix stripped. */
135 displayName: string | undefined;
136 /** User labels (Alchemy ownership labels stripped). */
137 labels: Record<string, string>;
138 /** Training phrases. */
139 trainingPhrases: IntentTrainingPhrase[] | undefined;
140 /** Intent parameters. */
141 parameters: IntentParameter[] | undefined;
142 /** Matching priority. */
143 priority: number | undefined;
144 /** Whether this is the fallback intent. */
145 isFallback: boolean;
146 /** User description with the Alchemy ownership prefix stripped. */
147 description: string | undefined;
148 /** DTMF pattern. */
149 dtmfPattern: string | undefined;
150 },
151 never,
152 Providers
153>;
154
155/**
156 * A Dialogflow CX intent (training phrases plus parameters).
157 *
158 * Alchemy ownership is stored in labels, `displayName`, and
159 * `description` so `list` / nuke can find the intent. Parent agent and
160 * id are immutable. Display name, labels, training phrases, parameters,
161 * priority, fallback flag, description, and DTMF pattern update in
162 * place.
163 *
164 * ### Creating an Intent
165 * **Example:** Greeting intent
166 * ```typescript
167 * const intent = yield* GCP.Dialogflow.AgentsIntent("Hello", {
168 * agent: agentName,
169 * displayName: "hello",
170 * trainingPhrases: [
171 * { parts: [{ text: "hello" }], repeatCount: 1 },
172 * { parts: [{ text: "hi there" }], repeatCount: 1 },
173 * ],
174 * labels: { env: "test" },
175 * });
176 * ```
177 *
178 * ### Updating an Intent
179 * Change props on the same logical id; the engine keeps the physical id.
180 *
181 * **Example:** Add a training phrase
182 * ```typescript
183 * const intent = yield* GCP.Dialogflow.AgentsIntent("Hello", {
184 * agent: agentName,
185 * displayName: "hello",
186 * trainingPhrases: [
187 * { parts: [{ text: "hello" }], repeatCount: 1 },
188 * { parts: [{ text: "good morning" }], repeatCount: 1 },
189 * ],
190 * labels: { env: "prod" },
191 * });
192 * ```
193 *
194 * @resource
195 * @category Dialogflow
196 */
197export const AgentsIntent = Resource<AgentsIntent>(
198 "GCP.Dialogflow.AgentsIntent",
199);
200
201export class AgentsIntentNotResolved extends Data.TaggedError(
202 "GCP.Dialogflow.AgentsIntentNotResolved",
203)<{
204 name: string;
205}> {}
206
207const resourceName = (agent: string, intentId: string) =>
208 `${agent}/intents/${intentId}`;
209
210const userLabels = (
211 labels: Record<string, string | undefined> | null | undefined,
212): Record<string, string> => stripInternalLabels(tagRecord(labels));
213
214const phrasesOf = (
215 phrases:
216 | dialogflow.GoogleCloudDialogflowCxV3IntentTrainingPhraseList
217 | undefined,
218): IntentTrainingPhrase[] | undefined => {
219 if (phrases === undefined) return undefined;
220 return phrases.map((phrase) => ({
221 parts: phrase.parts?.map((part) => ({
222 parameterId: part.parameterId,
223 text: part.text,
224 })),
225 repeatCount: phrase.repeatCount,
226 }));
227};
228
229const parametersOf = (
230 parameters:
231 | dialogflow.GoogleCloudDialogflowCxV3IntentParameterList
232 | undefined,
233): IntentParameter[] | undefined => {
234 if (parameters === undefined) return undefined;
235 return parameters.map((parameter) => ({
236 id: parameter.id,
237 entityType: parameter.entityType,
238 redact: parameter.redact,
239 isList: parameter.isList,
240 }));
241};
242
243const toAttrs = (
244 intent: dialogflow.GoogleCloudDialogflowCxV3Intent,
245 project: string,
246 agentHint?: string,
247) => {
248 const name = intent.name ?? "";
249 return {
250 name,
251 intentId: lastSegment(name),
252 agent: name.includes("/intents/")
253 ? collectionParent(name, "agents")
254 : (agentHint ?? parentOf(name)),
255 location: locationOf(name),
256 project: projectOf(name) || project,
257 displayName: parseOwnership(intent.displayName).text,
258 labels: userLabels(intent.labels),
259 trainingPhrases: phrasesOf(intent.trainingPhrases),
260 parameters: parametersOf(intent.parameters),
261 priority: intent.priority,
262 isFallback: intent.isFallback === true,
263 description: parseOwnership(intent.description).text,
264 dtmfPattern: intent.dtmfPattern,
265 };
266};
267
268const getByName = (name: string) =>
269 name.length === 0
270 ? Effect.succeed(undefined)
271 : dialogflow
272 .getProjectsLocationsAgentsIntents({ name })
273 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
274
275const isOwnedIntent = (intent: dialogflow.GoogleCloudDialogflowCxV3Intent) =>
276 hasOwnershipMarker(intent.displayName) ||
277 hasOwnershipMarker(intent.description) ||
278 Object.keys(intent.labels ?? {}).some((key) => key.startsWith("alchemy-"));
279
280const listAt = (parent: string, project: string) =>
281 dialogflow.listProjectsLocationsAgentsIntents
282 .pages({ parent, pageSize: 100, intentView: "INTENT_VIEW_FULL" })
283 .pipe(
284 Stream.flatMap((page) => Stream.fromIterable(page.intents ?? [])),
285 Stream.filter(isOwnedIntent),
286 Stream.map((intent) => toAttrs(intent, project, parent)),
287 Stream.runCollect,
288 Effect.map((chunk) => Array.from(chunk)),
289 Effect.catchTag("NotFound", () => Effect.succeed([])),
290 );
291
292const findByDisplayName = (parent: string, displayName: string) =>
293 dialogflow.listProjectsLocationsAgentsIntents
294 .pages({ parent, pageSize: 100, intentView: "INTENT_VIEW_PARTIAL" })
295 .pipe(
296 Stream.flatMap((page) => Stream.fromIterable(page.intents ?? [])),
297 Stream.filter((intent) => intent.displayName === displayName),
298 Stream.runHead,
299 Effect.map((option) =>
300 option._tag === "Some" ? option.value : undefined,
301 ),
302 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
303 );
304
305export const AgentsIntentProvider = () =>
306 Provider.succeed(AgentsIntent, {
307 stables: ["name", "intentId", "agent", "location", "project"],
308
309 diff: Effect.fn(function* ({ news, olds, output }) {
310 if (!isResolved(news)) return undefined;
311 const previousAgent = olds?.agent ?? output?.agent;
312 if (previousAgent !== undefined && news.agent !== previousAgent) {
313 return { action: "replace" as const, deleteFirst: false };
314 }
315 const previousId = olds?.intentId ?? output?.intentId;
316 if (
317 previousId !== undefined &&
318 news.intentId !== undefined &&
319 news.intentId !== previousId
320 ) {
321 return { action: "replace" as const, deleteFirst: false };
322 }
323 return undefined;
324 }),
325
326 read: Effect.fn(function* ({ id, olds, output }) {
327 const env = yield* GcpEnvironment.current;
328 const location = normalizeLocation(olds?.location ?? output?.location);
329 const agent = olds?.agent
330 ? expandAgent(olds.agent, env.project, location)
331 : output?.agent;
332 const intentId = yield* toResourceId(
333 id,
334 olds?.intentId,
335 output?.intentId,
336 );
337 const name =
338 output?.name ??
339 (agent !== undefined ? resourceName(agent, intentId) : "");
340 let existing = yield* getByName(name);
341 if (existing === undefined && agent !== undefined) {
342 const ownership = yield* createInternalLabels(id);
343 existing = yield* findByDisplayName(
344 agent,
345 encodeOwnershipLine(ownership, olds?.displayName),
346 );
347 }
348 if (existing === undefined) return undefined;
349 const attrs = toAttrs(existing, env.project, agent);
350 const labeled = yield* hasAlchemyLabels(id, tagRecord(existing.labels));
351 const named = yield* ownedByAlchemy(id, existing.displayName);
352 const described = yield* ownedByAlchemy(id, existing.description);
353 return labeled || named || described ? attrs : Unowned(attrs);
354 }),
355
356 list: () =>
357 Effect.gen(function* () {
358 const env = yield* GcpEnvironment.current;
359 const agents = yield* namedAgents(env.project);
360 const pages = yield* Effect.forEach(
361 agents,
362 (agent) => listAt(agent.name, env.project),
363 { concurrency: 4 },
364 );
365 return pages.flat();
366 }),
367
368 reconcile: Effect.fn(function* ({ id, news, output }) {
369 const env = yield* GcpEnvironment.current;
370 const location = normalizeLocation(
371 news.location ?? output?.location ?? DEFAULT_LOCATION,
372 );
373 const agent = expandAgent(news.agent, env.project, location);
374 const intentId = yield* toResourceId(id, news.intentId, output?.intentId);
375 const name = output?.name ?? resourceName(agent, intentId);
376 const ownership = yield* createInternalLabels(id);
377 const displayName = encodeOwnershipLine(ownership, news.displayName);
378 const description = encodeOwnership(ownership, news.description);
379 const desiredLabels = {
380 ...toLabels(news.labels),
381 ...ownership,
382 };
383 const isFallback = news.isFallback === true;
384 const body: dialogflow.GoogleCloudDialogflowCxV3Intent = {
385 displayName,
386 labels: desiredLabels,
387 trainingPhrases: news.trainingPhrases,
388 parameters: news.parameters,
389 priority: news.priority,
390 isFallback,
391 description,
392 dtmfPattern: news.dtmfPattern,
393 };
394
395 let current = yield* getByName(output?.name ?? name);
396 if (current === undefined) {
397 current = yield* findByDisplayName(agent, displayName);
398 }
399
400 if (current === undefined) {
401 const created = yield* dialogflow
402 .createProjectsLocationsAgentsIntents({
403 parent: agent,
404 languageCode: news.languageCode,
405 body,
406 })
407 .pipe(
408 Effect.catchTag("Conflict", () =>
409 findByDisplayName(agent, displayName),
410 ),
411 );
412 current = created ?? undefined;
413 }
414
415 if (current === undefined) {
416 return yield* new AgentsIntentNotResolved({ name });
417 }
418
419 const currentName = current.name ?? name;
420 const displayChanged = (current.displayName ?? "") !== displayName;
421 const observedLabels = tagRecord(current.labels);
422 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
423 const labelsChanged = upsert.length > 0 || removed.length > 0;
424 const phrasesChanged =
425 fingerprint(phrasesOf(current.trainingPhrases)) !==
426 fingerprint(news.trainingPhrases);
427 const parametersChanged =
428 fingerprint(parametersOf(current.parameters)) !==
429 fingerprint(news.parameters);
430 const priorityChanged = (current.priority ?? 0) !== (news.priority ?? 0);
431 const fallbackChanged = (current.isFallback === true) !== isFallback;
432 const descriptionChanged = (current.description ?? "") !== description;
433 const dtmfChanged = !sameText(current.dtmfPattern, news.dtmfPattern);
434
435 if (
436 displayChanged ||
437 labelsChanged ||
438 phrasesChanged ||
439 parametersChanged ||
440 priorityChanged ||
441 fallbackChanged ||
442 descriptionChanged ||
443 dtmfChanged
444 ) {
445 current = yield* dialogflow.patchProjectsLocationsAgentsIntents({
446 name: currentName,
447 languageCode: news.languageCode,
448 updateMask: updateMaskOf(
449 displayChanged ? "display_name" : undefined,
450 labelsChanged ? "labels" : undefined,
451 phrasesChanged ? "training_phrases" : undefined,
452 parametersChanged ? "parameters" : undefined,
453 priorityChanged ? "priority" : undefined,
454 fallbackChanged ? "is_fallback" : undefined,
455 descriptionChanged ? "description" : undefined,
456 dtmfChanged ? "dtmf_pattern" : undefined,
457 ),
458 body: { ...body, name: currentName },
459 });
460 }
461
462 return toAttrs(current, env.project, agent);
463 }, retryQuota),
464
465 delete: Effect.fn(function* ({ output }) {
466 yield* dialogflow
467 .deleteProjectsLocationsAgentsIntents({ name: output.name })
468 .pipe(Effect.catchTag("NotFound", () => Effect.void));
469 }, retryQuota),
470 });