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 toResourceId,
23 updateMaskOf,
24} from "./ownership.ts";
25
26export type ScheduleInfo = {
27 /** Time zone used to interpret `schedule`. Defaults to UTC. */
28 timeZone?: string;
29 /** RFC3339 start. Omitted starts as soon as the rule is created. */
30 startTime?: string;
31 /** RFC3339 end. Omitted keeps scheduling until the rule is deleted. */
32 endTime?: string;
33 /**
34 * Groc expression (e.g. `"every 5 minutes"` or
35 * `"every 1 hours synchronized"`).
36 */
37 schedule?: string;
38};
39
40export type SampleRule = {
41 /**
42 * Group-by dimension. Currently
43 * `quality_metadata.agent_info.agent_id` is supported. Empty samples
44 * at the project level.
45 */
46 dimension?: string;
47 /** Filter selecting conversations to sample. Empty applies to all. */
48 conversationFilter?: string;
49 /** Fraction of conversations to sample, in `[0, 1]`. */
50 samplePercentage?: number;
51 /** Absolute number of conversations to sample. */
52 sampleRow?: string;
53};
54
55export type AssessmentRuleProps = {
56 /**
57 * Assessment rule id (the `{assessment_rule}` segment of
58 * `projects/{project}/locations/{location}/assessmentRules/{assessment_rule}`).
59 * If omitted, a unique id is generated. Must match `^[A-Za-z0-9]{4,64}$`
60 * (no dashes). Immutable — changing it replaces the rule.
61 */
62 assessmentRuleId?: string;
63 /**
64 * Location (`us-central1`, …). Immutable — changing it replaces the rule.
65 * `US-CENTRAL1` is accepted and normalized to `us-central1`.
66 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
67 */
68 location?: string;
69 /**
70 * Display name. Assessment rules have no labels field, so Alchemy
71 * ownership is stored in a `[alchemy …]` prefix and stripped from
72 * attributes.
73 */
74 displayName?: string;
75 /**
76 * When true, the rule is applied to sampled conversations.
77 * @default false
78 */
79 active?: boolean;
80 /** Sampling configuration. */
81 sampleRule?: SampleRule;
82 /** Schedule on which sampled conversations are assessed. */
83 scheduleInfo?: ScheduleInfo;
84};
85
86export type AssessmentRule = Resource<
87 "GCP.ContactCenterInsights.AssessmentRule",
88 AssessmentRuleProps,
89 {
90 /** Full resource name `projects/{project}/locations/{location}/assessmentRules/{assessment_rule}`. */
91 name: string;
92 /** Assessment rule id (last path segment). */
93 assessmentRuleId: string;
94 /** Location id. */
95 location: string;
96 /** Project id. */
97 project: string;
98 /** User display name with the Alchemy ownership prefix stripped. */
99 displayName: string | undefined;
100 /** Whether the rule is active. */
101 active: boolean;
102 /** Sampling configuration. */
103 sampleRule: SampleRule | undefined;
104 /** Schedule configuration. */
105 scheduleInfo: ScheduleInfo | undefined;
106 /** RFC3339 creation timestamp. */
107 createTime: string | undefined;
108 /** RFC3339 last-update timestamp. */
109 updateTime: string | undefined;
110 },
111 never,
112 Providers
113>;
114
115/**
116 * A Contact Center AI Insights assessment rule that samples conversations
117 * on a schedule for quality assessment.
118 *
119 * Assessment rules have no labels field — Alchemy stamps ownership into
120 * the display name. Location and id are immutable. Active flag, sample
121 * rule, and schedule update in place.
122 *
123 * ### Creating an Assessment Rule
124 * **Example:** Inactive hourly sample
125 * ```typescript
126 * const rule = yield* GCP.ContactCenterInsights.AssessmentRule("Qa", {
127 * displayName: "hourly-qa",
128 * active: false,
129 * sampleRule: { samplePercentage: 0.1 },
130 * scheduleInfo: { schedule: "every 1 hours", timeZone: "UTC" },
131 * });
132 * ```
133 *
134 * @resource
135 * @category ContactCenterInsights
136 */
137export const AssessmentRule = Resource<AssessmentRule>(
138 "GCP.ContactCenterInsights.AssessmentRule",
139);
140
141export class AssessmentRuleNotResolved extends Data.TaggedError(
142 "GCP.ContactCenterInsights.AssessmentRuleNotResolved",
143)<{
144 name: string;
145}> {}
146
147// Assessment rule ids allow only `[A-Za-z0-9]{4,64}`, unlike the dashed
148// ids `toResourceId` generates.
149const toAssessmentRuleId = (
150 id: string,
151 requested: string | undefined,
152 existing: string | undefined,
153) =>
154 requested !== undefined || existing !== undefined
155 ? toResourceId(id, requested, existing)
156 : toResourceId(id, undefined, undefined).pipe(
157 Effect.map((generated) => generated.replace(/[^a-z0-9]/g, "")),
158 );
159
160const resourceName = (
161 project: string,
162 location: string,
163 assessmentRuleId: string,
164) => `${locationParent(project, location)}/assessmentRules/${assessmentRuleId}`;
165
166const sampleOf = (
167 sample: cci.GoogleCloudContactcenterinsightsV1SampleRule | undefined,
168): SampleRule | undefined => {
169 if (sample === undefined) return undefined;
170 return {
171 dimension: sample.dimension,
172 conversationFilter: sample.conversationFilter,
173 samplePercentage: sample.samplePercentage,
174 sampleRow: sample.sampleRow,
175 };
176};
177
178const scheduleOf = (
179 schedule: cci.GoogleCloudContactcenterinsightsV1ScheduleInfo | undefined,
180): ScheduleInfo | undefined => {
181 if (schedule === undefined) return undefined;
182 return {
183 timeZone: schedule.timeZone,
184 startTime: schedule.startTime,
185 endTime: schedule.endTime,
186 schedule: schedule.schedule,
187 };
188};
189
190const toAttrs = (
191 rule: cci.GoogleCloudContactcenterinsightsV1AssessmentRule,
192 project: string,
193) => {
194 const name = rule.name ?? "";
195 const parsed = parseOwnership(rule.displayName);
196 return {
197 name,
198 assessmentRuleId: lastSegment(name),
199 location: locationOf(name),
200 project,
201 displayName: parsed.text,
202 active: rule.active === true,
203 sampleRule: sampleOf(rule.sampleRule),
204 scheduleInfo: scheduleOf(rule.scheduleInfo),
205 createTime: rule.createTime,
206 updateTime: rule.updateTime,
207 };
208};
209
210const getByName = (name: string) =>
211 name.length === 0
212 ? Effect.succeed(undefined)
213 : cci
214 .getProjectsLocationsAssessmentRules({ name })
215 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
216
217const listAt = (parent: string, project: string) =>
218 cci.listProjectsLocationsAssessmentRules
219 .pages({ parent, pageSize: 100 })
220 .pipe(
221 Stream.flatMap((page) => Stream.fromIterable(page.assessmentRules ?? [])),
222 Stream.filter((rule) => hasOwnershipMarker(rule.displayName)),
223 Stream.map((rule) => toAttrs(rule, project)),
224 Stream.runCollect,
225 Effect.map((chunk) => Array.from(chunk)),
226 Effect.catchTag("NotFound", () => Effect.succeed([])),
227 );
228
229/** Assessment rules reject display names over 64 characters ("Request contains an invalid argument"). */
230const MAX_RULE_DISPLAY_NAME = 64;
231
232export const AssessmentRuleProvider = () =>
233 Provider.succeed(AssessmentRule, {
234 stables: ["name", "assessmentRuleId", "location", "project", "createTime"],
235
236 diff: Effect.fn(function* ({ news, olds, output }) {
237 if (!isResolved(news)) return undefined;
238 const previousLocation = olds?.location ?? output?.location;
239 if (
240 previousLocation !== undefined &&
241 news.location !== undefined &&
242 normalizeLocation(previousLocation) !== normalizeLocation(news.location)
243 ) {
244 return { action: "replace" as const, deleteFirst: false };
245 }
246 const previousId = olds?.assessmentRuleId ?? output?.assessmentRuleId;
247 if (
248 previousId !== undefined &&
249 news.assessmentRuleId !== undefined &&
250 news.assessmentRuleId !== previousId
251 ) {
252 return { action: "replace" as const, deleteFirst: false };
253 }
254 return undefined;
255 }),
256
257 read: Effect.fn(function* ({ id, olds, output }) {
258 const env = yield* GcpEnvironment.current;
259 const assessmentRuleId = yield* toAssessmentRuleId(
260 id,
261 olds?.assessmentRuleId,
262 output?.assessmentRuleId,
263 );
264 const location = normalizeLocation(
265 olds?.location ?? output?.location ?? env.region,
266 );
267 const name =
268 output?.name ?? resourceName(env.project, location, assessmentRuleId);
269 const existing = yield* getByName(name);
270 if (existing === undefined) return undefined;
271 const attrs = toAttrs(existing, env.project);
272 return (yield* ownedByAlchemy(id, existing.displayName))
273 ? attrs
274 : Unowned(attrs);
275 }),
276
277 list: () =>
278 Effect.gen(function* () {
279 const env = yield* GcpEnvironment.current;
280 return yield* listAt(
281 locationParent(env.project, env.region),
282 env.project,
283 );
284 }),
285
286 reconcile: Effect.fn(function* ({ id, news, output }) {
287 const env = yield* GcpEnvironment.current;
288 const location = normalizeLocation(
289 news.location ?? output?.location ?? env.region,
290 );
291 const parent = locationParent(env.project, location);
292 const assessmentRuleId = yield* toAssessmentRuleId(
293 id,
294 news.assessmentRuleId,
295 output?.assessmentRuleId,
296 );
297 const name = resourceName(env.project, location, assessmentRuleId);
298 const ownership = yield* createInternalLabels(id);
299 const displayName = encodeOwnershipLine(
300 ownership,
301 news.displayName,
302 MAX_RULE_DISPLAY_NAME,
303 );
304 const active = news.active === true;
305
306 let current = yield* getByName(output?.name ?? name);
307
308 if (current === undefined) {
309 const created = yield* cci
310 .createProjectsLocationsAssessmentRules({
311 parent,
312 assessmentRuleId,
313 body: {
314 displayName,
315 active,
316 sampleRule: news.sampleRule,
317 scheduleInfo: news.scheduleInfo,
318 },
319 })
320 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
321 current = created ?? undefined;
322 }
323
324 if (current === undefined) {
325 return yield* new AssessmentRuleNotResolved({ name });
326 }
327
328 const currentName = current.name ?? name;
329 const displayChanged = (current.displayName ?? "") !== displayName;
330 const activeChanged = (current.active === true) !== active;
331 const sampleChanged = !jsonEqual(
332 sampleOf(current.sampleRule),
333 news.sampleRule,
334 );
335 const scheduleChanged = !jsonEqual(
336 scheduleOf(current.scheduleInfo),
337 news.scheduleInfo,
338 );
339
340 if (displayChanged || activeChanged || sampleChanged || scheduleChanged) {
341 current = yield* cci.patchProjectsLocationsAssessmentRules({
342 name: currentName,
343 updateMask: updateMaskOf(
344 displayChanged ? "display_name" : undefined,
345 activeChanged ? "active" : undefined,
346 sampleChanged ? "sample_rule" : undefined,
347 scheduleChanged ? "schedule_info" : undefined,
348 ),
349 body: {
350 name: currentName,
351 displayName,
352 active,
353 sampleRule: news.sampleRule,
354 scheduleInfo: news.scheduleInfo,
355 },
356 });
357 }
358
359 return toAttrs(current, env.project);
360 }),
361
362 delete: Effect.fn(function* ({ output }) {
363 yield* cci
364 .deleteProjectsLocationsAssessmentRules({ name: output.name })
365 .pipe(Effect.catchTag("NotFound", () => Effect.void));
366 }),
367 });