1import * as cci from "@distilled.cloud/gcp/contactcenterinsights_v1";
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 { GcpEnvironment } from "../Environment.ts";
10import { createInternalLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 encodeOwnershipLine,
14 hasOwnershipMarker,
15 jsonEqual,
16 lastSegment,
17 locationOf,
18 locationParent,
19 normalizeLocation,
20 ownedByAlchemy,
21 parseOwnership,
22 sameText,
23 updateMaskOf,
24} from "./ownership.ts";
25
26export type PhraseMatcherType =
27 | "PHRASE_MATCHER_TYPE_UNSPECIFIED"
28 | "ALL_OF"
29 | "ANY_OF";
30
31export type PhraseMatcherRole =
32 | "ROLE_UNSPECIFIED"
33 | "HUMAN_AGENT"
34 | "AUTOMATED_AGENT"
35 | "END_USER"
36 | "ANY_AGENT";
37
38export type PhraseMatchRuleGroupType =
39 | "PHRASE_MATCH_RULE_GROUP_TYPE_UNSPECIFIED"
40 | "ALL_OF"
41 | "ANY_OF";
42
43export type ExactMatchConfig = {
44 /** Whether matching is case sensitive. */
45 caseSensitive?: boolean;
46};
47
48export type PhraseMatchRule = {
49 /** Phrase or regex to match. */
50 query?: string;
51 /** When true, the phrase must be absent from the transcript. */
52 negated?: boolean;
53 /** Match configuration. */
54 config?: {
55 /** Exact-match options. */
56 exactMatchConfig?: ExactMatchConfig;
57 };
58};
59
60export type PhraseMatchRuleGroup = {
61 /** How rules in this group combine (`ALL_OF` or `ANY_OF`). */
62 type?: PhraseMatchRuleGroupType;
63 /** Phrase match rules in this group. */
64 phraseMatchRules?: PhraseMatchRule[];
65};
66
67export type PhraseMatcherProps = {
68 /**
69 * Phrase matcher id (the `{phrase_matcher}` segment of
70 * `projects/{project}/locations/{location}/phraseMatchers/{phrase_matcher}`).
71 * If omitted, a unique id is generated. Immutable — changing it replaces
72 * the matcher.
73 */
74 phraseMatcherId?: string;
75 /**
76 * Location (`us-central1`, …). Immutable — changing it replaces the
77 * matcher. `US-CENTRAL1` is accepted and normalized to `us-central1`.
78 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
79 */
80 location?: string;
81 /**
82 * Display name. Phrase matchers have no labels field, so Alchemy
83 * ownership is stored in a `[alchemy …]` prefix and stripped from
84 * attributes.
85 */
86 displayName?: string;
87 /**
88 * How rule groups combine. Required by the API.
89 * @default "ALL_OF"
90 */
91 type?: PhraseMatcherType;
92 /**
93 * When true, the matcher is applied to conversations.
94 * @default false
95 */
96 active?: boolean;
97 /**
98 * Speaker role whose utterances this matcher evaluates.
99 */
100 roleMatch?: PhraseMatcherRole;
101 /**
102 * Custom version tag. Defaults to the server revision id when omitted.
103 */
104 versionTag?: string;
105 /**
106 * Phrase match rule groups.
107 */
108 phraseMatchRuleGroups?: PhraseMatchRuleGroup[];
109};
110
111export type PhraseMatcher = Resource<
112 "GCP.ContactCenterInsights.PhraseMatcher",
113 PhraseMatcherProps,
114 {
115 /** Full resource name `projects/{project}/locations/{location}/phraseMatchers/{phrase_matcher}`. */
116 name: string;
117 /** Phrase matcher id (last path segment). */
118 phraseMatcherId: string;
119 /** Location id. */
120 location: string;
121 /** Project id. */
122 project: string;
123 /** User display name with the Alchemy ownership prefix stripped. */
124 displayName: string | undefined;
125 /** How rule groups combine. */
126 type: string | undefined;
127 /** Whether the matcher is active. */
128 active: boolean;
129 /** Speaker role match. */
130 roleMatch: string | undefined;
131 /** Custom version tag. */
132 versionTag: string | undefined;
133 /** Phrase match rule groups. */
134 phraseMatchRuleGroups: PhraseMatchRuleGroup[] | undefined;
135 /** Server revision id. */
136 revisionId: string | undefined;
137 /** RFC3339 revision create timestamp. */
138 revisionCreateTime: string | undefined;
139 /** RFC3339 last-update timestamp. */
140 updateTime: string | undefined;
141 /** RFC3339 last activation-status change. */
142 activationUpdateTime: string | undefined;
143 },
144 never,
145 Providers
146>;
147
148/**
149 * A Contact Center AI Insights phrase matcher that flags conversations
150 * containing configured phrases.
151 *
152 * Phrase matchers have no labels field — Alchemy stamps ownership into
153 * the display name so `list` / nuke can find them. Location and id are
154 * immutable. Display name, type, active flag, role, version tag, and
155 * rule groups update in place.
156 *
157 * ### Creating a Phrase Matcher
158 * **Example:** Inactive exact-match matcher
159 * ```typescript
160 * const matcher = yield* GCP.ContactCenterInsights.PhraseMatcher("Refunds", {
161 * displayName: "refunds",
162 * type: "ALL_OF",
163 * active: false,
164 * phraseMatchRuleGroups: [
165 * {
166 * type: "ANY_OF",
167 * phraseMatchRules: [
168 * {
169 * query: "refund",
170 * config: { exactMatchConfig: { caseSensitive: false } },
171 * },
172 * ],
173 * },
174 * ],
175 * });
176 * ```
177 *
178 * @resource
179 * @category ContactCenterInsights
180 */
181export const PhraseMatcher = Resource<PhraseMatcher>(
182 "GCP.ContactCenterInsights.PhraseMatcher",
183);
184
185export class PhraseMatcherNotResolved extends Data.TaggedError(
186 "GCP.ContactCenterInsights.PhraseMatcherNotResolved",
187)<{
188 name: string;
189}> {}
190
191const DEFAULT_TYPE: PhraseMatcherType = "ALL_OF";
192
193const resourceName = (
194 project: string,
195 location: string,
196 phraseMatcherId: string,
197) => `${locationParent(project, location)}/phraseMatchers/${phraseMatcherId}`;
198
199const groupsOf = (
200 groups:
201 | cci.GoogleCloudContactcenterinsightsV1PhraseMatchRuleGroupList
202 | undefined,
203): PhraseMatchRuleGroup[] | undefined => {
204 if (groups === undefined) return undefined;
205 return groups.map((group) => ({
206 type: group.type as PhraseMatchRuleGroupType | undefined,
207 phraseMatchRules: group.phraseMatchRules?.map((rule) => ({
208 query: rule.query,
209 negated: rule.negated,
210 config: rule.config
211 ? {
212 exactMatchConfig: rule.config.exactMatchConfig
213 ? {
214 caseSensitive: rule.config.exactMatchConfig.caseSensitive,
215 }
216 : undefined,
217 }
218 : undefined,
219 })),
220 }));
221};
222
223const toAttrs = (
224 matcher: cci.GoogleCloudContactcenterinsightsV1PhraseMatcher,
225 project: string,
226) => {
227 const name = matcher.name ?? "";
228 const parsed = parseOwnership(matcher.displayName);
229 return {
230 name,
231 phraseMatcherId: lastSegment(name),
232 location: locationOf(name),
233 project,
234 displayName: parsed.text,
235 type: matcher.type,
236 active: matcher.active === true,
237 roleMatch: matcher.roleMatch,
238 versionTag: matcher.versionTag,
239 phraseMatchRuleGroups: groupsOf(matcher.phraseMatchRuleGroups),
240 revisionId: matcher.revisionId,
241 revisionCreateTime: matcher.revisionCreateTime,
242 updateTime: matcher.updateTime,
243 activationUpdateTime: matcher.activationUpdateTime,
244 };
245};
246
247const getByName = (name: string) =>
248 name.length === 0
249 ? Effect.succeed(undefined)
250 : cci
251 .getProjectsLocationsPhraseMatchers({ name })
252 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
253
254const listAt = (parent: string, project: string) =>
255 cci.listProjectsLocationsPhraseMatchers.pages({ parent, pageSize: 100 }).pipe(
256 Stream.flatMap((page) => Stream.fromIterable(page.phraseMatchers ?? [])),
257 Stream.filter((matcher) => hasOwnershipMarker(matcher.displayName)),
258 Stream.map((matcher) => toAttrs(matcher, project)),
259 Stream.runCollect,
260 Effect.map((chunk) => Array.from(chunk)),
261 Effect.catchTag("NotFound", () => Effect.succeed([])),
262 );
263
264const findByDisplayName = (parent: string, displayName: string) =>
265 cci.listProjectsLocationsPhraseMatchers.pages({ parent, pageSize: 100 }).pipe(
266 Stream.flatMap((page) => Stream.fromIterable(page.phraseMatchers ?? [])),
267 Stream.filter((matcher) => matcher.displayName === displayName),
268 Stream.runHead,
269 Effect.map((option) => (option._tag === "Some" ? option.value : undefined)),
270 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
271 );
272
273export const PhraseMatcherProvider = () =>
274 Provider.succeed(PhraseMatcher, {
275 stables: [
276 "name",
277 "phraseMatcherId",
278 "location",
279 "project",
280 "revisionCreateTime",
281 ],
282
283 diff: Effect.fn(function* ({ news, olds, output }) {
284 if (!isResolved(news)) return undefined;
285 const previousLocation = olds?.location ?? output?.location;
286 if (
287 previousLocation !== undefined &&
288 news.location !== undefined &&
289 normalizeLocation(previousLocation) !== normalizeLocation(news.location)
290 ) {
291 return { action: "replace" as const, deleteFirst: false };
292 }
293 const previousId = olds?.phraseMatcherId ?? output?.phraseMatcherId;
294 if (
295 previousId !== undefined &&
296 news.phraseMatcherId !== undefined &&
297 news.phraseMatcherId !== previousId
298 ) {
299 return { action: "replace" as const, deleteFirst: false };
300 }
301 return undefined;
302 }),
303
304 read: Effect.fn(function* ({ id, olds, output }) {
305 const env = yield* GcpEnvironment.current;
306 const location = normalizeLocation(
307 olds?.location ?? output?.location ?? env.region,
308 );
309 const knownId = olds?.phraseMatcherId ?? output?.phraseMatcherId;
310 const name =
311 output?.name ??
312 (knownId ? resourceName(env.project, location, knownId) : "");
313 let existing = yield* getByName(name);
314 if (existing === undefined) {
315 const ownership = yield* createInternalLabels(id);
316 existing = yield* findByDisplayName(
317 locationParent(env.project, location),
318 encodeOwnershipLine(ownership, olds?.displayName),
319 );
320 }
321 if (existing === undefined) return undefined;
322 const attrs = toAttrs(existing, env.project);
323 return (yield* ownedByAlchemy(id, existing.displayName))
324 ? attrs
325 : Unowned(attrs);
326 }),
327
328 list: () =>
329 Effect.gen(function* () {
330 const env = yield* GcpEnvironment.current;
331 return yield* listAt(
332 locationParent(env.project, env.region),
333 env.project,
334 );
335 }),
336
337 reconcile: Effect.fn(function* ({ id, news, output }) {
338 const env = yield* GcpEnvironment.current;
339 const location = normalizeLocation(
340 news.location ?? output?.location ?? env.region,
341 );
342 const parent = locationParent(env.project, location);
343 const knownId = news.phraseMatcherId ?? output?.phraseMatcherId;
344 const name =
345 output?.name ??
346 (knownId ? resourceName(env.project, location, knownId) : "");
347 const ownership = yield* createInternalLabels(id);
348 const displayName = encodeOwnershipLine(ownership, news.displayName);
349 const type = news.type ?? DEFAULT_TYPE;
350 const active = news.active === true;
351 const phraseMatchRuleGroups = news.phraseMatchRuleGroups;
352
353 let current = yield* getByName(name);
354 if (current === undefined) {
355 current = yield* findByDisplayName(parent, displayName);
356 }
357
358 if (current === undefined) {
359 const created = yield* cci
360 .createProjectsLocationsPhraseMatchers({
361 parent,
362 body: {
363 displayName,
364 type,
365 active,
366 roleMatch: news.roleMatch,
367 versionTag: news.versionTag,
368 phraseMatchRuleGroups,
369 },
370 })
371 .pipe(
372 Effect.catchTag("Conflict", () =>
373 findByDisplayName(parent, displayName),
374 ),
375 );
376 current = created ?? (yield* findByDisplayName(parent, displayName));
377 }
378
379 if (current === undefined) {
380 return yield* new PhraseMatcherNotResolved({ name });
381 }
382
383 const currentName = current.name ?? name;
384 const displayChanged = (current.displayName ?? "") !== displayName;
385 const typeChanged = (current.type ?? DEFAULT_TYPE) !== type;
386 const activeChanged = (current.active === true) !== active;
387 const roleChanged = !sameText(current.roleMatch, news.roleMatch);
388 const versionChanged = !sameText(current.versionTag, news.versionTag);
389 const groupsChanged = !jsonEqual(
390 groupsOf(current.phraseMatchRuleGroups),
391 phraseMatchRuleGroups,
392 );
393
394 if (
395 displayChanged ||
396 typeChanged ||
397 activeChanged ||
398 roleChanged ||
399 versionChanged ||
400 groupsChanged
401 ) {
402 current = yield* cci.patchProjectsLocationsPhraseMatchers({
403 name: currentName,
404 updateMask: updateMaskOf(
405 displayChanged ? "display_name" : undefined,
406 typeChanged ? "type" : undefined,
407 activeChanged ? "active" : undefined,
408 roleChanged ? "role_match" : undefined,
409 versionChanged ? "version_tag" : undefined,
410 groupsChanged ? "phrase_match_rule_groups" : undefined,
411 ),
412 body: {
413 name: currentName,
414 displayName,
415 type,
416 active,
417 roleMatch: news.roleMatch,
418 versionTag: news.versionTag,
419 phraseMatchRuleGroups,
420 },
421 });
422 }
423
424 return toAttrs(current, env.project);
425 }),
426
427 delete: Effect.fn(function* ({ output }) {
428 yield* cci
429 .deleteProjectsLocationsPhraseMatchers({ name: output.name })
430 .pipe(Effect.catchTag("NotFound", () => Effect.void));
431 }),
432 });