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 toResourceId,
24 updateMaskOf,
25} from "./ownership.ts";
26
27export type AnnotatorSelector = {
28 /** Run the sentiment annotator. */
29 runSentimentAnnotator?: boolean;
30 /** Run the summarization annotator. */
31 runSummarizationAnnotator?: boolean;
32 /** Run the QA annotator. */
33 runQaAnnotator?: boolean;
34 /** Run the silence annotator. */
35 runSilenceAnnotator?: boolean;
36 /** Run active phrase matcher annotators. */
37 runPhraseMatcherAnnotator?: boolean;
38 /** Run the entity annotator. */
39 runEntityAnnotator?: boolean;
40 /** Run the intent annotator. */
41 runIntentAnnotator?: boolean;
42 /** Run the interruption annotator. */
43 runInterruptionAnnotator?: boolean;
44 /** Run the issue-model annotator. */
45 runIssueModelAnnotator?: boolean;
46 /** Run the auto-labeling annotator. */
47 runAutoLabelingAnnotator?: boolean;
48 /** Issue model resource names. Used when `runIssueModelAnnotator` is true. */
49 issueModels?: string[];
50 /** Phrase matcher resource names. Used when `runPhraseMatcherAnnotator` is true. */
51 phraseMatchers?: string[];
52};
53
54export type AnalysisRuleProps = {
55 /**
56 * Analysis rule id (the `{analysis_rule}` segment of
57 * `projects/{project}/locations/{location}/analysisRules/{analysis_rule}`).
58 * Contact Center AI Insights assigns the id on create; set it only to
59 * manage an existing rule. Immutable — changing it replaces the rule.
60 */
61 analysisRuleId?: string;
62 /**
63 * Location (`us-central1`, …). Immutable — changing it replaces the rule.
64 * `US-CENTRAL1` is accepted and normalized to `us-central1`.
65 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
66 */
67 location?: string;
68 /**
69 * Display name. Analysis rules have no labels field, so Alchemy
70 * ownership is stored in a `[alchemy …]` prefix and stripped from
71 * attributes.
72 */
73 displayName?: string;
74 /**
75 * Filter selecting conversations this rule applies to. Empty applies to
76 * all conversations.
77 */
78 conversationFilter?: string;
79 /**
80 * When true, the rule is applied to matching conversations. When false,
81 * it is stored as a draft.
82 * @default false
83 */
84 active?: boolean;
85 /**
86 * Fraction of matching conversations to analyze automatically, in `[0, 1]`.
87 * @default 0
88 */
89 analysisPercentage?: number;
90 /**
91 * Annotators to run on matching conversations. Omitted means no
92 * annotators run.
93 */
94 annotatorSelector?: AnnotatorSelector;
95};
96
97export type AnalysisRule = Resource<
98 "GCP.ContactCenterInsights.AnalysisRule",
99 AnalysisRuleProps,
100 {
101 /** Full resource name `projects/{project}/locations/{location}/analysisRules/{analysis_rule}`. */
102 name: string;
103 /** Analysis rule id (last path segment). */
104 analysisRuleId: string;
105 /** Location id. */
106 location: string;
107 /** Project id. */
108 project: string;
109 /** User display name with the Alchemy ownership prefix stripped. */
110 displayName: string | undefined;
111 /** Conversation filter. */
112 conversationFilter: string | undefined;
113 /** Whether the rule is active. */
114 active: boolean;
115 /** Automatic analysis percentage. */
116 analysisPercentage: number | undefined;
117 /** Annotator selector. */
118 annotatorSelector: AnnotatorSelector | undefined;
119 /** RFC3339 creation timestamp. */
120 createTime: string | undefined;
121 /** RFC3339 last-update timestamp. */
122 updateTime: string | undefined;
123 },
124 never,
125 Providers
126>;
127
128/**
129 * A Contact Center AI Insights analysis rule that selects conversations
130 * and the annotators to run on them.
131 *
132 * Analysis rules have no labels field — Alchemy stamps ownership into the
133 * display name so `list` / nuke can find them. Location and id are
134 * immutable. Filter, active flag, analysis percentage, and annotators
135 * update in place.
136 *
137 * ### Creating an Analysis Rule
138 * **Example:** Inactive draft rule
139 * ```typescript
140 * const rule = yield* GCP.ContactCenterInsights.AnalysisRule("Draft", {
141 * displayName: "draft-sentiment",
142 * active: false,
143 * analysisPercentage: 0,
144 * annotatorSelector: { runSentimentAnnotator: true },
145 * });
146 * ```
147 *
148 * **Example:** Named rule with a conversation filter
149 * ```typescript
150 * const rule = yield* GCP.ContactCenterInsights.AnalysisRule("Draft", {
151 * analysisRuleId: "sentiment-en",
152 * conversationFilter: 'language_code="en-US"',
153 * active: false,
154 * annotatorSelector: { runSentimentAnnotator: true },
155 * });
156 * ```
157 *
158 * @resource
159 * @category ContactCenterInsights
160 */
161export const AnalysisRule = Resource<AnalysisRule>(
162 "GCP.ContactCenterInsights.AnalysisRule",
163);
164
165export class AnalysisRuleNotResolved extends Data.TaggedError(
166 "GCP.ContactCenterInsights.AnalysisRuleNotResolved",
167)<{
168 name: string;
169}> {}
170
171const resourceName = (
172 project: string,
173 location: string,
174 analysisRuleId: string,
175) => `${locationParent(project, location)}/analysisRules/${analysisRuleId}`;
176
177const selectorOf = (
178 selector: cci.GoogleCloudContactcenterinsightsV1AnnotatorSelector | undefined,
179): AnnotatorSelector | undefined => {
180 if (selector === undefined) return undefined;
181 return {
182 runSentimentAnnotator: selector.runSentimentAnnotator,
183 runSummarizationAnnotator: selector.runSummarizationAnnotator,
184 runQaAnnotator: selector.runQaAnnotator,
185 runSilenceAnnotator: selector.runSilenceAnnotator,
186 runPhraseMatcherAnnotator: selector.runPhraseMatcherAnnotator,
187 runEntityAnnotator: selector.runEntityAnnotator,
188 runIntentAnnotator: selector.runIntentAnnotator,
189 runInterruptionAnnotator: selector.runInterruptionAnnotator,
190 runIssueModelAnnotator: selector.runIssueModelAnnotator,
191 runAutoLabelingAnnotator: selector.runAutoLabelingAnnotator,
192 issueModels: selector.issueModels,
193 phraseMatchers: selector.phraseMatchers,
194 };
195};
196
197const toAttrs = (
198 rule: cci.GoogleCloudContactcenterinsightsV1AnalysisRule,
199 project: string,
200) => {
201 const name = rule.name ?? "";
202 const parsed = parseOwnership(rule.displayName);
203 return {
204 name,
205 analysisRuleId: lastSegment(name),
206 location: locationOf(name),
207 project,
208 displayName: parsed.text,
209 conversationFilter: rule.conversationFilter,
210 active: rule.active === true,
211 analysisPercentage: rule.analysisPercentage,
212 annotatorSelector: selectorOf(rule.annotatorSelector),
213 createTime: rule.createTime,
214 updateTime: rule.updateTime,
215 };
216};
217
218const getByName = (name: string) =>
219 name.length === 0
220 ? Effect.succeed(undefined)
221 : cci
222 .getProjectsLocationsAnalysisRules({ name })
223 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
224
225const listAt = (parent: string, project: string) =>
226 cci.listProjectsLocationsAnalysisRules.pages({ parent, pageSize: 100 }).pipe(
227 Stream.flatMap((page) => Stream.fromIterable(page.analysisRules ?? [])),
228 Stream.filter((rule) => hasOwnershipMarker(rule.displayName)),
229 Stream.map((rule) => toAttrs(rule, project)),
230 Stream.runCollect,
231 Effect.map((chunk) => Array.from(chunk)),
232 Effect.catchTag("NotFound", () => Effect.succeed([])),
233 );
234
235const findByDisplayName = (parent: string, displayName: string) =>
236 cci.listProjectsLocationsAnalysisRules.pages({ parent, pageSize: 100 }).pipe(
237 Stream.flatMap((page) => Stream.fromIterable(page.analysisRules ?? [])),
238 Stream.filter((rule) => rule.displayName === displayName),
239 Stream.runHead,
240 Effect.map((option) => (option._tag === "Some" ? option.value : undefined)),
241 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
242 );
243
244/** Analysis rules reject display names over 64 characters ("Request contains an invalid argument"). */
245const MAX_RULE_DISPLAY_NAME = 64;
246
247export const AnalysisRuleProvider = () =>
248 Provider.succeed(AnalysisRule, {
249 stables: ["name", "analysisRuleId", "location", "project", "createTime"],
250
251 diff: Effect.fn(function* ({ news, olds, output }) {
252 if (!isResolved(news)) return undefined;
253 const previousLocation = olds?.location ?? output?.location;
254 if (
255 previousLocation !== undefined &&
256 news.location !== undefined &&
257 normalizeLocation(previousLocation) !== normalizeLocation(news.location)
258 ) {
259 return { action: "replace" as const, deleteFirst: false };
260 }
261 const previousId = olds?.analysisRuleId ?? output?.analysisRuleId;
262 if (
263 previousId !== undefined &&
264 news.analysisRuleId !== undefined &&
265 news.analysisRuleId !== previousId
266 ) {
267 return { action: "replace" as const, deleteFirst: false };
268 }
269 return undefined;
270 }),
271
272 read: Effect.fn(function* ({ id, olds, output }) {
273 const env = yield* GcpEnvironment.current;
274 const analysisRuleId = yield* toResourceId(
275 id,
276 olds?.analysisRuleId,
277 output?.analysisRuleId,
278 );
279 const location = normalizeLocation(
280 olds?.location ?? output?.location ?? env.region,
281 );
282 const name =
283 output?.name ?? resourceName(env.project, location, analysisRuleId);
284 let existing = yield* getByName(name);
285 if (existing === undefined && output?.name === undefined) {
286 const ownership = yield* createInternalLabels(id);
287 existing = yield* findByDisplayName(
288 locationParent(env.project, location),
289 encodeOwnershipLine(
290 ownership,
291 olds?.displayName,
292 MAX_RULE_DISPLAY_NAME,
293 ),
294 );
295 }
296 if (existing === undefined) return undefined;
297 const attrs = toAttrs(existing, env.project);
298 return (yield* ownedByAlchemy(id, existing.displayName))
299 ? attrs
300 : Unowned(attrs);
301 }),
302
303 list: () =>
304 Effect.gen(function* () {
305 const env = yield* GcpEnvironment.current;
306 return yield* listAt(
307 locationParent(env.project, env.region),
308 env.project,
309 );
310 }),
311
312 reconcile: Effect.fn(function* ({ id, news, output }) {
313 const env = yield* GcpEnvironment.current;
314 const location = normalizeLocation(
315 news.location ?? output?.location ?? env.region,
316 );
317 const parent = locationParent(env.project, location);
318 const analysisRuleId = yield* toResourceId(
319 id,
320 news.analysisRuleId,
321 output?.analysisRuleId,
322 );
323 const name =
324 output?.name ?? resourceName(env.project, location, analysisRuleId);
325 const ownership = yield* createInternalLabels(id);
326 const displayName = encodeOwnershipLine(
327 ownership,
328 news.displayName,
329 MAX_RULE_DISPLAY_NAME,
330 );
331 const active = news.active === true;
332 const analysisPercentage = news.analysisPercentage ?? 0;
333 const annotatorSelector = news.annotatorSelector;
334
335 // Insights assigns rule ids itself, so a generated id is only a
336 // placeholder; look the rule up by recorded name or display name.
337 let current =
338 output?.name !== undefined || news.analysisRuleId !== undefined
339 ? yield* getByName(name)
340 : undefined;
341 if (current === undefined) {
342 current = yield* findByDisplayName(parent, displayName);
343 }
344
345 if (current === undefined) {
346 const created = yield* cci
347 .createProjectsLocationsAnalysisRules({
348 parent,
349 body: {
350 displayName,
351 conversationFilter: news.conversationFilter,
352 active,
353 analysisPercentage,
354 annotatorSelector,
355 },
356 })
357 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
358 current = created ?? (yield* findByDisplayName(parent, displayName));
359 }
360
361 if (current === undefined) {
362 return yield* new AnalysisRuleNotResolved({ name });
363 }
364
365 const currentName = current.name ?? name;
366 const displayChanged = (current.displayName ?? "") !== displayName;
367 const filterChanged = !sameText(
368 current.conversationFilter,
369 news.conversationFilter,
370 );
371 const activeChanged = (current.active === true) !== active;
372 const percentageChanged =
373 (current.analysisPercentage ?? 0) !== analysisPercentage;
374 const selectorChanged = !jsonEqual(
375 selectorOf(current.annotatorSelector),
376 annotatorSelector,
377 );
378
379 if (
380 displayChanged ||
381 filterChanged ||
382 activeChanged ||
383 percentageChanged ||
384 selectorChanged
385 ) {
386 current = yield* cci.patchProjectsLocationsAnalysisRules({
387 name: currentName,
388 updateMask: updateMaskOf(
389 displayChanged ? "display_name" : undefined,
390 filterChanged ? "conversation_filter" : undefined,
391 activeChanged ? "active" : undefined,
392 percentageChanged ? "analysis_percentage" : undefined,
393 selectorChanged ? "annotator_selector" : undefined,
394 ),
395 body: {
396 name: currentName,
397 displayName,
398 conversationFilter: news.conversationFilter,
399 active,
400 analysisPercentage,
401 annotatorSelector,
402 },
403 });
404 }
405
406 return toAttrs(current, env.project);
407 }),
408
409 delete: Effect.fn(function* ({ output }) {
410 yield* cci
411 .deleteProjectsLocationsAnalysisRules({ name: output.name })
412 .pipe(Effect.catchTag("NotFound", () => Effect.void));
413 }),
414 });