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