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