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 lastSegment,
16 locationOf,
17 locationParent,
18 MAX_LABEL_LENGTH,
19 ownedByAlchemy,
20 parentOf,
21 parseOwnership,
22 sameJson,
23 toResourceId,
24} from "./ownership.ts";
25
26type QaAnswerLabel = {
27 /** Skip this question. */
28 skipValue?: boolean;
29 /** Not-applicable value. */
30 naValue?: boolean;
31 /** Answer-choice key. */
32 key?: string;
33 /** Numeric value. */
34 numValue?: number;
35 /** Boolean value. */
36 boolValue?: boolean;
37 /** String value. */
38 strValue?: string;
39};
40
41export type ConversationsFeedbackLabelProps = {
42 /**
43 * Parent Conversation resource name
44 * (`projects/{project}/locations/{location}/conversations/{conversation}`).
45 * Immutable — changing it replaces the feedback label.
46 */
47 parent: string;
48 /**
49 * Feedback label id (the `{feedback_label}` segment). If omitted, a
50 * unique id is generated. Immutable — changing it replaces the label.
51 */
52 feedbackLabelId?: string;
53 /**
54 * Topic-modeling string label. Feedback labels have no labels field, so
55 * Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
56 * from attributes.
57 */
58 label?: string;
59 /**
60 * Resource being labeled (scorecard question, issue model, or generator).
61 */
62 labeledResource?: string;
63 /**
64 * Quality-AI answer label.
65 */
66 qaAnswerLabel?: QaAnswerLabel;
67};
68
69export type ConversationsFeedbackLabel = Resource<
70 "GCP.ContactCenterInsights.ConversationsFeedbackLabel",
71 ConversationsFeedbackLabelProps,
72 {
73 /** Full resource name. */
74 name: string;
75 /** Feedback label id (last path segment). */
76 feedbackLabelId: string;
77 /** Parent conversation resource name. */
78 parent: string;
79 /** Region id. */
80 location: string;
81 /** Project id. */
82 project: string;
83 /** User label with the Alchemy ownership prefix stripped. */
84 label: string | undefined;
85 /** Resource being labeled. */
86 labeledResource: string | undefined;
87 /** Quality-AI answer label. */
88 qaAnswerLabel: QaAnswerLabel | undefined;
89 /** RFC3339 creation timestamp. */
90 createTime: string | undefined;
91 /** RFC3339 last-update timestamp. */
92 updateTime: string | undefined;
93 },
94 never,
95 Providers
96>;
97
98/**
99 * A feedback label on a Contact Center Insights conversation.
100 *
101 * Parent conversation and feedback label id are immutable. Feedback
102 * labels have no labels map — Alchemy stamps ownership into the `label`
103 * string. The label text, labeled resource, and QA answer update in
104 * place.
105 *
106 * ### Creating a Feedback Label
107 * **Example:** Topic-modeling label
108 * ```typescript
109 * const label = yield* GCP.ContactCenterInsights.ConversationsFeedbackLabel(
110 * "Topic",
111 * {
112 * parent: conversation.name,
113 * label: "billing",
114 * },
115 * );
116 * ```
117 *
118 * @resource
119 * @category ContactCenterInsights
120 */
121export const ConversationsFeedbackLabel = Resource<ConversationsFeedbackLabel>(
122 "GCP.ContactCenterInsights.ConversationsFeedbackLabel",
123);
124
125export class ConversationsFeedbackLabelNotResolved extends Data.TaggedError(
126 "GCP.ContactCenterInsights.ConversationsFeedbackLabelNotResolved",
127)<{
128 name: string;
129}> {}
130
131const resourceName = (parent: string, feedbackLabelId: string) =>
132 `${parent}/feedbackLabels/${feedbackLabelId}`;
133
134const toQa = (
135 value:
136 | cci.GoogleCloudContactcenterinsightsV1QaAnswerAnswerValue
137 | QaAnswerLabel
138 | undefined,
139): QaAnswerLabel | undefined => {
140 if (value === undefined) return undefined;
141 return {
142 skipValue: value.skipValue,
143 naValue: value.naValue,
144 key: value.key,
145 numValue: value.numValue,
146 boolValue: value.boolValue,
147 strValue: value.strValue,
148 };
149};
150
151const toAttrs = (
152 label: cci.GoogleCloudContactcenterinsightsV1FeedbackLabel,
153 project: string,
154) => {
155 const name = label.name ?? "";
156 const parsed = parseOwnership(label.label);
157 return {
158 name,
159 feedbackLabelId: lastSegment(name),
160 parent: parentOf(name),
161 location: locationOf(name),
162 project,
163 label: parsed.text,
164 labeledResource: label.labeledResource,
165 qaAnswerLabel: toQa(label.qaAnswerLabel),
166 createTime: label.createTime,
167 updateTime: label.updateTime,
168 };
169};
170
171const getByName = (name: string) =>
172 name.length === 0
173 ? Effect.succeed(undefined)
174 : cci
175 .getProjectsLocationsConversationsFeedbackLabels({ name })
176 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
177
178const listAll = (parent: string, project: string) =>
179 cci.listAllFeedbackLabelsProjectsLocations
180 .pages({ parent, pageSize: 100 })
181 .pipe(
182 Stream.flatMap((page) => Stream.fromIterable(page.feedbackLabels ?? [])),
183 Stream.filter((label) => hasOwnershipMarker(label.label)),
184 Stream.filter((label) => !label.name?.includes("/datasets/")),
185 Stream.map((label) => toAttrs(label, project)),
186 Stream.runCollect,
187 Effect.map((chunk) => Array.from(chunk)),
188 Effect.catchTag("NotFound", () => Effect.succeed([])),
189 );
190
191export const ConversationsFeedbackLabelProvider = () =>
192 Provider.succeed(ConversationsFeedbackLabel, {
193 stables: [
194 "name",
195 "feedbackLabelId",
196 "parent",
197 "location",
198 "project",
199 "createTime",
200 ],
201
202 diff: Effect.fn(function* ({ news, olds, output }) {
203 if (!isResolved(news)) return undefined;
204 const previousParent = olds?.parent ?? output?.parent;
205 if (previousParent !== undefined && news.parent !== previousParent) {
206 return { action: "replace" as const, deleteFirst: false };
207 }
208 const previousId = olds?.feedbackLabelId ?? output?.feedbackLabelId;
209 if (
210 previousId !== undefined &&
211 news.feedbackLabelId !== undefined &&
212 news.feedbackLabelId !== previousId
213 ) {
214 return { action: "replace" as const, deleteFirst: true };
215 }
216 return undefined;
217 }),
218
219 read: Effect.fn(function* ({ id, olds, output }) {
220 const env = yield* GcpEnvironment.current;
221 const feedbackLabelId = yield* toResourceId(
222 id,
223 olds?.feedbackLabelId,
224 output?.feedbackLabelId,
225 );
226 const name =
227 output?.name ??
228 (olds?.parent !== undefined
229 ? resourceName(olds.parent, feedbackLabelId)
230 : "");
231 const existing = yield* getByName(name);
232 if (existing === undefined) return undefined;
233 const attrs = toAttrs(existing, env.project);
234 return (yield* ownedByAlchemy(id, existing.label))
235 ? attrs
236 : Unowned(attrs);
237 }),
238
239 list: () =>
240 Effect.gen(function* () {
241 const env = yield* GcpEnvironment.current;
242 return yield* listAll(
243 locationParent(env.project, env.region),
244 env.project,
245 );
246 }),
247
248 reconcile: Effect.fn(function* ({ id, news, output }) {
249 const env = yield* GcpEnvironment.current;
250 const feedbackLabelId = yield* toResourceId(
251 id,
252 news.feedbackLabelId,
253 output?.feedbackLabelId,
254 );
255 const name = resourceName(news.parent, feedbackLabelId);
256 const ownership = yield* createInternalLabels(id);
257 const label = encodeOwnershipLine(
258 ownership,
259 news.label,
260 MAX_LABEL_LENGTH,
261 );
262
263 let current = yield* getByName(output?.name ?? name);
264
265 if (current === undefined) {
266 const created = yield* cci
267 .createProjectsLocationsConversationsFeedbackLabels({
268 parent: news.parent,
269 feedbackLabelId,
270 body: {
271 label,
272 labeledResource: news.labeledResource,
273 qaAnswerLabel: news.qaAnswerLabel,
274 },
275 })
276 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
277 current = created ?? undefined;
278 }
279
280 if (current === undefined) {
281 return yield* new ConversationsFeedbackLabelNotResolved({ name });
282 }
283
284 const labelChanged = (current.label ?? "") !== label;
285 const resourceChanged =
286 (current.labeledResource ?? "") !== (news.labeledResource ?? "");
287 const qaChanged = !sameJson(
288 toQa(current.qaAnswerLabel),
289 news.qaAnswerLabel,
290 );
291
292 if (labelChanged || resourceChanged || qaChanged) {
293 current = yield* cci.patchProjectsLocationsConversationsFeedbackLabels({
294 name: current.name ?? name,
295 updateMask: [
296 labelChanged ? "label" : undefined,
297 resourceChanged ? "labeled_resource" : undefined,
298 qaChanged ? "qa_answer_label" : undefined,
299 ]
300 .filter((field): field is string => field !== undefined)
301 .join(","),
302 body: {
303 name: current.name ?? name,
304 label,
305 labeledResource: news.labeledResource,
306 qaAnswerLabel: news.qaAnswerLabel,
307 },
308 });
309 }
310
311 return toAttrs(current, env.project);
312 }),
313
314 delete: Effect.fn(function* ({ output }) {
315 yield* cci
316 .deleteProjectsLocationsConversationsFeedbackLabels({
317 name: output.name,
318 })
319 .pipe(Effect.catchTag("NotFound", () => Effect.void));
320 }),
321 });