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 encodeOwnership,
14 encodeOwnershipLine,
15 hasOwnershipMarker,
16 jsonEqual,
17 lastSegment,
18 locationOf,
19 locationParent,
20 normalizeLocation,
21 ownedByAlchemy,
22 parseOwnership,
23 sameText,
24 toResourceId,
25 updateMaskOf,
26} from "./ownership.ts";
27
28export type LabelingCondition = {
29 /** CEL expression evaluated as the label value. */
30 value?: string;
31 /**
32 * Optional CEL predicate. When true (or empty), `value` is used. The
33 * first matching condition wins.
34 */
35 condition?: string;
36};
37
38export type AutoLabelingRuleProps = {
39 /**
40 * Auto-labeling rule id (the `{auto_labeling_rule}` segment of
41 * `projects/{project}/locations/{location}/autoLabelingRules/{auto_labeling_rule}`).
42 * If omitted, a unique id is generated. Immutable — changing it replaces
43 * the rule. Also used as `labelKey` when `labelKeyType` is
44 * `LABEL_KEY_TYPE_CUSTOM`.
45 */
46 autoLabelingRuleId?: string;
47 /**
48 * Location (`us-central1`, …). Immutable — changing it replaces the rule.
49 * `US-CENTRAL1` is accepted and normalized to `us-central1`.
50 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
51 */
52 location?: string;
53 /**
54 * User-facing display name.
55 */
56 displayName?: string;
57 /**
58 * Human-readable description. Auto-labeling rules have no labels field,
59 * so Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
60 * from attributes.
61 */
62 description?: string;
63 /**
64 * When true, the rule is applied to conversations.
65 * @default false
66 */
67 active?: boolean;
68 /**
69 * Label key written onto matching conversations. Defaults to
70 * `autoLabelingRuleId`. Only settable when `labelKeyType` is
71 * `LABEL_KEY_TYPE_CUSTOM`.
72 */
73 labelKey?: string;
74 /**
75 * Label key type.
76 * @default "LABEL_KEY_TYPE_CUSTOM"
77 */
78 labelKeyType?:
79 | "LABEL_KEY_TYPE_UNSPECIFIED"
80 | "LABEL_KEY_TYPE_CUSTOM"
81 | (string & {});
82 /**
83 * Sequential if / else-if conditions. The value of the first matching
84 * condition is used.
85 */
86 conditions?: LabelingCondition[];
87};
88
89export type AutoLabelingRule = Resource<
90 "GCP.ContactCenterInsights.AutoLabelingRule",
91 AutoLabelingRuleProps,
92 {
93 /** Full resource name `projects/{project}/locations/{location}/autoLabelingRules/{auto_labeling_rule}`. */
94 name: string;
95 /** Auto-labeling rule id (last path segment). */
96 autoLabelingRuleId: string;
97 /** Location id. */
98 location: string;
99 /** Project id. */
100 project: string;
101 /** User-facing display name. */
102 displayName: string | undefined;
103 /** User description with the Alchemy ownership prefix stripped. */
104 description: string | undefined;
105 /** Whether the rule is active. */
106 active: boolean;
107 /** Label key. */
108 labelKey: string | undefined;
109 /** Label key type. */
110 labelKeyType: string | undefined;
111 /** Labeling conditions. */
112 conditions: LabelingCondition[];
113 /** RFC3339 creation timestamp. */
114 createTime: string | undefined;
115 /** RFC3339 last-update timestamp. */
116 updateTime: string | undefined;
117 },
118 never,
119 Providers
120>;
121
122/**
123 * A Contact Center AI Insights auto-labeling rule that writes a custom
124 * label onto matching conversations.
125 *
126 * Auto-labeling rules have no labels field — Alchemy stamps ownership into
127 * the description. Location and id are immutable. Display name,
128 * description, active flag, and conditions update in place.
129 *
130 * ### Creating an Auto-Labeling Rule
131 * **Example:** Inactive custom label
132 * ```typescript
133 * const rule = yield* GCP.ContactCenterInsights.AutoLabelingRule("Topic", {
134 * displayName: "billing-topic",
135 * active: false,
136 * conditions: [{ value: '"billing"', condition: "true" }],
137 * });
138 * ```
139 *
140 * @resource
141 * @category ContactCenterInsights
142 */
143export const AutoLabelingRule = Resource<AutoLabelingRule>(
144 "GCP.ContactCenterInsights.AutoLabelingRule",
145);
146
147export class AutoLabelingRuleNotResolved extends Data.TaggedError(
148 "GCP.ContactCenterInsights.AutoLabelingRuleNotResolved",
149)<{
150 name: string;
151}> {}
152
153const DEFAULT_LABEL_KEY_TYPE = "LABEL_KEY_TYPE_CUSTOM";
154
155const resourceName = (
156 project: string,
157 location: string,
158 autoLabelingRuleId: string,
159) =>
160 `${locationParent(project, location)}/autoLabelingRules/${autoLabelingRuleId}`;
161
162const conditionsOf = (
163 list:
164 | readonly cci.GoogleCloudContactcenterinsightsV1AutoLabelingRuleLabelingCondition[]
165 | undefined,
166): LabelingCondition[] =>
167 (list ?? []).map((condition) => ({
168 value: condition.value,
169 condition: condition.condition,
170 }));
171
172const toAttrs = (
173 rule: cci.GoogleCloudContactcenterinsightsV1AutoLabelingRule,
174 project: string,
175) => {
176 const name = rule.name ?? "";
177 const parsed = parseOwnership(rule.description);
178 return {
179 name,
180 autoLabelingRuleId: lastSegment(name),
181 location: locationOf(name),
182 project,
183 displayName: rule.displayName,
184 description: parsed.text,
185 active: rule.active === true,
186 labelKey: rule.labelKey,
187 labelKeyType: rule.labelKeyType,
188 conditions: conditionsOf(rule.conditions),
189 createTime: rule.createTime,
190 updateTime: rule.updateTime,
191 };
192};
193
194const getByName = (name: string) =>
195 name.length === 0
196 ? Effect.succeed(undefined)
197 : cci
198 .getProjectsLocationsAutoLabelingRules({ name })
199 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
200
201const listAt = (parent: string, project: string) =>
202 cci.listProjectsLocationsAutoLabelingRules
203 .pages({ parent, pageSize: 100 })
204 .pipe(
205 Stream.flatMap((page) =>
206 Stream.fromIterable(page.autoLabelingRules ?? []),
207 ),
208 Stream.filter((rule) => hasOwnershipMarker(rule.description)),
209 Stream.map((rule) => toAttrs(rule, project)),
210 Stream.runCollect,
211 Effect.map((chunk) => Array.from(chunk)),
212 Effect.catchTag("NotFound", () => Effect.succeed([])),
213 );
214
215export const AutoLabelingRuleProvider = () =>
216 Provider.succeed(AutoLabelingRule, {
217 stables: [
218 "name",
219 "autoLabelingRuleId",
220 "location",
221 "project",
222 "labelKey",
223 "createTime",
224 ],
225
226 diff: Effect.fn(function* ({ news, olds, output }) {
227 if (!isResolved(news)) return undefined;
228 const previousLocation = olds?.location ?? output?.location;
229 if (
230 previousLocation !== undefined &&
231 news.location !== undefined &&
232 normalizeLocation(previousLocation) !== normalizeLocation(news.location)
233 ) {
234 return { action: "replace" as const, deleteFirst: false };
235 }
236 const previousId = olds?.autoLabelingRuleId ?? output?.autoLabelingRuleId;
237 if (
238 previousId !== undefined &&
239 news.autoLabelingRuleId !== undefined &&
240 news.autoLabelingRuleId !== previousId
241 ) {
242 return { action: "replace" as const, deleteFirst: false };
243 }
244 return undefined;
245 }),
246
247 read: Effect.fn(function* ({ id, olds, output }) {
248 const env = yield* GcpEnvironment.current;
249 const autoLabelingRuleId = yield* toResourceId(
250 id,
251 olds?.autoLabelingRuleId,
252 output?.autoLabelingRuleId,
253 );
254 const location = normalizeLocation(
255 olds?.location ?? output?.location ?? env.region,
256 );
257 const name =
258 output?.name ?? resourceName(env.project, location, autoLabelingRuleId);
259 const existing = yield* getByName(name);
260 if (existing === undefined) return undefined;
261 const attrs = toAttrs(existing, env.project);
262 return (yield* ownedByAlchemy(id, existing.description))
263 ? attrs
264 : Unowned(attrs);
265 }),
266
267 list: () =>
268 Effect.gen(function* () {
269 const env = yield* GcpEnvironment.current;
270 return yield* listAt(
271 locationParent(env.project, env.region),
272 env.project,
273 );
274 }),
275
276 reconcile: Effect.fn(function* ({ id, news, output }) {
277 const env = yield* GcpEnvironment.current;
278 const location = normalizeLocation(
279 news.location ?? output?.location ?? env.region,
280 );
281 const parent = locationParent(env.project, location);
282 const autoLabelingRuleId = yield* toResourceId(
283 id,
284 news.autoLabelingRuleId,
285 output?.autoLabelingRuleId,
286 );
287 const name = resourceName(env.project, location, autoLabelingRuleId);
288 const ownership = yield* createInternalLabels(id);
289 const description = encodeOwnership(ownership, news.description);
290 const displayName =
291 news.displayName ?? encodeOwnershipLine(ownership, undefined);
292 const active = news.active === true;
293 const labelKeyType = news.labelKeyType ?? DEFAULT_LABEL_KEY_TYPE;
294 const labelKey = news.labelKey ?? autoLabelingRuleId;
295 const conditions = news.conditions ?? [];
296
297 let current = yield* getByName(output?.name ?? name);
298
299 if (current === undefined) {
300 const created = yield* cci
301 .createProjectsLocationsAutoLabelingRules({
302 parent,
303 autoLabelingRuleId,
304 body: {
305 displayName,
306 description,
307 active,
308 labelKey,
309 labelKeyType,
310 conditions,
311 },
312 })
313 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
314 current = created ?? undefined;
315 }
316
317 if (current === undefined) {
318 return yield* new AutoLabelingRuleNotResolved({ name });
319 }
320
321 const currentName = current.name ?? name;
322 const displayChanged = !sameText(current.displayName, displayName);
323 const descriptionChanged = !sameText(current.description, description);
324 const activeChanged = (current.active === true) !== active;
325 const conditionsChanged = !jsonEqual(
326 conditionsOf(current.conditions),
327 conditions,
328 );
329
330 if (
331 displayChanged ||
332 descriptionChanged ||
333 activeChanged ||
334 conditionsChanged
335 ) {
336 current = yield* cci.patchProjectsLocationsAutoLabelingRules({
337 name: currentName,
338 updateMask: updateMaskOf(
339 displayChanged ? "display_name" : undefined,
340 descriptionChanged ? "description" : undefined,
341 activeChanged ? "active" : undefined,
342 conditionsChanged ? "conditions" : undefined,
343 ),
344 body: {
345 name: currentName,
346 displayName,
347 description,
348 active,
349 conditions,
350 },
351 });
352 }
353
354 return toAttrs(current, env.project);
355 }),
356
357 delete: Effect.fn(function* ({ output }) {
358 yield* cci
359 .deleteProjectsLocationsAutoLabelingRules({ name: output.name })
360 .pipe(Effect.catchTag("NotFound", () => Effect.void));
361 }),
362 });