1import * as cci from "@distilled.cloud/gcp/contactcenterinsights_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import { createInternalLabels } from "../Labels.ts";
12import type { Providers } from "../Providers.ts";
13import {
14 encodeOwnership,
15 hasOwnershipMarker,
16 jsonEqual,
17 lastSegment,
18 locationOf,
19 locationParent,
20 ownedByAlchemy,
21 parentOf,
22 parseOwnership,
23 sameText,
24 toResourceId,
25 updateMaskOf,
26} from "./ownership.ts";
27
28export type QaQuestionType =
29 | "QA_QUESTION_TYPE_UNSPECIFIED"
30 | "CUSTOMIZABLE"
31 | "PREDEFINED";
32
33export type QaQuestionAnswerChoice = {
34 /** String answer value. */
35 strValue?: string;
36 /** Short identifier for the choice. */
37 key?: string;
38 /** Numerical answer value. */
39 numValue?: number;
40 /** Boolean answer value. */
41 boolValue?: boolean;
42 /** When true, the answer is N/A and excluded from scoring. */
43 naValue?: boolean;
44 /** Numerical score contributed by this choice. */
45 score?: number;
46};
47
48export type QaQuestionPredefinedQuestionConfig = {
49 /** Predefined question type. */
50 type?:
51 | "PREDEFINED_QUESTION_TYPE_UNSPECIFIED"
52 | "CONVERSATION_OUTCOME"
53 | "CONVERSATION_OUTCOME_ESCALATION_INITIATOR_ROLE"
54 | (string & {});
55};
56
57export type QaQuestionDataOptions = {
58 /** Conversation transcript options used to generate the question. */
59 conversationDataOptions?: {
60 /** Include per-turn Dialogflow interaction data. */
61 includeDialogflowInteractionData?: boolean;
62 };
63};
64
65export type QaScorecardsRevisionsQaQuestionProps = {
66 /**
67 * Parent QaScorecardRevision resource name
68 * (`projects/{project}/locations/{location}/qaScorecards/{qa_scorecard}/revisions/{revision}`).
69 * Immutable — changing it replaces the question.
70 */
71 parent: string;
72 /**
73 * Question id (the `{qa_question}` segment). If omitted, a unique id is
74 * generated. Must match `^[a-z0-9-]{4,64}$`. Immutable — changing it
75 * replaces the question.
76 */
77 qaQuestionId?: string;
78 /**
79 * Full question text, e.g. "Did the agent greet the customer?"
80 */
81 questionBody?: string;
82 /**
83 * Short UI label, e.g. "Greeting".
84 */
85 abbreviation?: string;
86 /**
87 * Question type.
88 */
89 questionType?: QaQuestionType;
90 /**
91 * Instructions describing how to determine the answer. Questions have
92 * no labels field, so Alchemy ownership is stored in a `[alchemy …]`
93 * prefix and stripped from attributes.
94 */
95 answerInstructions?: string;
96 /**
97 * Order of the question within its parent revision.
98 */
99 order?: number;
100 /**
101 * Valid answers the model must choose from.
102 */
103 answerChoices?: QaQuestionAnswerChoice[];
104 /**
105 * Default or custom tags used to group and score the question.
106 */
107 tags?: string[];
108 /**
109 * Configuration for a predefined question. Set when `questionType` is
110 * `PREDEFINED`.
111 */
112 predefinedQuestionConfig?: QaQuestionPredefinedQuestionConfig;
113 /**
114 * Options for conversation data used to generate the question.
115 */
116 qaQuestionDataOptions?: QaQuestionDataOptions;
117};
118
119export type QaScorecardsRevisionsQaQuestion = Resource<
120 "GCP.ContactCenterInsights.QaScorecardsRevisionsQaQuestion",
121 QaScorecardsRevisionsQaQuestionProps,
122 {
123 /** Full resource name. */
124 name: string;
125 /** Question id (last path segment). */
126 qaQuestionId: string;
127 /** Parent revision resource name. */
128 parent: string;
129 /** Location id. */
130 location: string;
131 /** Project id. */
132 project: string;
133 /** Question text. */
134 questionBody: string | undefined;
135 /** Short UI label. */
136 abbreviation: string | undefined;
137 /** Question type. */
138 questionType: string | undefined;
139 /** User instructions with the Alchemy ownership prefix stripped. */
140 answerInstructions: string | undefined;
141 /** Order within the parent revision. */
142 order: number | undefined;
143 /** Valid answer choices. */
144 answerChoices: QaQuestionAnswerChoice[] | undefined;
145 /** Grouping tags. */
146 tags: string[];
147 /** Predefined question config. */
148 predefinedQuestionConfig: QaQuestionPredefinedQuestionConfig | undefined;
149 /** Question data options. */
150 qaQuestionDataOptions: QaQuestionDataOptions | undefined;
151 /** RFC3339 creation timestamp. */
152 createTime: string | undefined;
153 /** RFC3339 last-update timestamp. */
154 updateTime: string | undefined;
155 },
156 never,
157 Providers
158>;
159
160/**
161 * A question on a Contact Center AI Insights QA scorecard revision.
162 *
163 * Parent revision and question id are immutable. Questions have no
164 * labels field — Alchemy stamps ownership into `answerInstructions`.
165 * Question body, abbreviation, instructions, order, choices, and tags
166 * update in place while the parent revision is `EDITABLE`.
167 *
168 * ### Creating a QA Question
169 * **Example:** Greeting question
170 * ```typescript
171 * const question = yield* GCP.ContactCenterInsights.QaScorecardsRevisionsQaQuestion(
172 * "Greeting",
173 * {
174 * parent: revision.name,
175 * abbreviation: "Greeting",
176 * questionBody: "Did the agent greet the customer?",
177 * answerChoices: [
178 * { strValue: "Yes", score: 1 },
179 * { strValue: "No", score: 0 },
180 * ],
181 * },
182 * );
183 * ```
184 *
185 * @resource
186 * @category ContactCenterInsights
187 */
188export const QaScorecardsRevisionsQaQuestion =
189 Resource<QaScorecardsRevisionsQaQuestion>(
190 "GCP.ContactCenterInsights.QaScorecardsRevisionsQaQuestion",
191 );
192
193export class QaScorecardsRevisionsQaQuestionNotResolved extends Data.TaggedError(
194 "GCP.ContactCenterInsights.QaScorecardsRevisionsQaQuestionNotResolved",
195)<{
196 name: string;
197}> {}
198
199const resourceName = (parent: string, qaQuestionId: string) =>
200 `${parent}/qaQuestions/${qaQuestionId}`;
201
202const choicesOf = (
203 choices:
204 | cci.GoogleCloudContactcenterinsightsV1QaQuestionAnswerChoiceList
205 | undefined,
206): QaQuestionAnswerChoice[] | undefined => {
207 if (choices === undefined) return undefined;
208 return choices.map((choice) => ({
209 strValue: choice.strValue,
210 key: choice.key,
211 numValue: choice.numValue,
212 boolValue: choice.boolValue,
213 naValue: choice.naValue,
214 score: choice.score,
215 }));
216};
217
218const optionalString = (value: string | undefined): string | undefined => value;
219
220const toAttrs = (
221 question: cci.GoogleCloudContactcenterinsightsV1QaQuestion,
222 project: string,
223) => {
224 const name = question.name ?? "";
225 const parsed = parseOwnership(question.answerInstructions);
226 return {
227 name,
228 qaQuestionId: lastSegment(name),
229 parent: parentOf(name),
230 location: locationOf(name),
231 project,
232 questionBody: question.questionBody,
233 abbreviation: question.abbreviation,
234 questionType: optionalString(question.questionType),
235 answerInstructions: parsed.text,
236 order: question.order,
237 answerChoices: choicesOf(question.answerChoices),
238 tags: [...(question.tags ?? [])],
239 predefinedQuestionConfig: question.predefinedQuestionConfig
240 ? { type: question.predefinedQuestionConfig.type }
241 : undefined,
242 qaQuestionDataOptions: question.qaQuestionDataOptions
243 ? {
244 conversationDataOptions: question.qaQuestionDataOptions
245 .conversationDataOptions
246 ? {
247 includeDialogflowInteractionData:
248 question.qaQuestionDataOptions.conversationDataOptions
249 .includeDialogflowInteractionData,
250 }
251 : undefined,
252 }
253 : undefined,
254 createTime: question.createTime,
255 updateTime: question.updateTime,
256 };
257};
258
259const getByName = (name: string) =>
260 name.length === 0
261 ? Effect.succeed(undefined)
262 : cci
263 .getProjectsLocationsQaScorecardsRevisionsQaQuestions({ name })
264 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
265
266const listRevisions = (parent: string) =>
267 cci.listProjectsLocationsQaScorecardsRevisions
268 .pages({ parent, pageSize: 100 })
269 .pipe(
270 Stream.flatMap((page) =>
271 Stream.fromIterable(page.qaScorecardRevisions ?? []),
272 ),
273 Stream.map((revision) => revision.name ?? ""),
274 Stream.filter((name) => name.length > 0),
275 Stream.runCollect,
276 Effect.map((chunk) => Array.from(chunk)),
277 Effect.catchTag("NotFound", () => Effect.succeed([] as string[])),
278 );
279
280const listAtParent = (parent: string, project: string) =>
281 cci.listProjectsLocationsQaScorecardsRevisionsQaQuestions
282 .pages({ parent, pageSize: 100 })
283 .pipe(
284 Stream.flatMap((page) => Stream.fromIterable(page.qaQuestions ?? [])),
285 Stream.filter((question) =>
286 hasOwnershipMarker(question.answerInstructions),
287 ),
288 Stream.map((question) => toAttrs(question, project)),
289 Stream.runCollect,
290 Effect.map((chunk) => Array.from(chunk)),
291 Effect.catchTag("NotFound", () => Effect.succeed([])),
292 );
293
294export const QaScorecardsRevisionsQaQuestionProvider = () =>
295 Provider.succeed(QaScorecardsRevisionsQaQuestion, {
296 stables: [
297 "name",
298 "qaQuestionId",
299 "parent",
300 "location",
301 "project",
302 "createTime",
303 ],
304
305 diff: Effect.fn(function* ({ news, olds, output }) {
306 if (!isResolved(news)) return undefined;
307 const previousParent = olds?.parent ?? output?.parent;
308 if (previousParent !== undefined && news.parent !== previousParent) {
309 return { action: "replace" as const, deleteFirst: false };
310 }
311 const previousId = olds?.qaQuestionId ?? output?.qaQuestionId;
312 if (
313 previousId !== undefined &&
314 news.qaQuestionId !== undefined &&
315 news.qaQuestionId !== previousId
316 ) {
317 return { action: "replace" as const, deleteFirst: true };
318 }
319 return undefined;
320 }),
321
322 read: Effect.fn(function* ({ id, olds, output }) {
323 const env = yield* GcpEnvironment.current;
324 const qaQuestionId = yield* toResourceId(
325 id,
326 olds?.qaQuestionId,
327 output?.qaQuestionId,
328 );
329 const name =
330 output?.name ??
331 (olds?.parent !== undefined
332 ? resourceName(olds.parent, qaQuestionId)
333 : "");
334 const existing = yield* getByName(name);
335 if (existing === undefined) return undefined;
336 const attrs = toAttrs(existing, env.project);
337 return (yield* ownedByAlchemy(id, existing.answerInstructions))
338 ? attrs
339 : Unowned(attrs);
340 }),
341
342 list: () =>
343 Effect.gen(function* () {
344 const env = yield* GcpEnvironment.current;
345 const revisions = yield* listRevisions(
346 `${locationParent(env.project, env.region)}/qaScorecards/-`,
347 );
348 const pages = yield* Effect.forEach(
349 revisions,
350 (parent) => listAtParent(parent, env.project),
351 { concurrency: 4 },
352 );
353 return pages.flat();
354 }),
355
356 reconcile: Effect.fn(function* ({ id, news, output }) {
357 const env = yield* GcpEnvironment.current;
358 const qaQuestionId = yield* toResourceId(
359 id,
360 news.qaQuestionId,
361 output?.qaQuestionId,
362 );
363 const name = resourceName(news.parent, qaQuestionId);
364 const ownership = yield* createInternalLabels(id);
365 const answerInstructions = encodeOwnership(
366 ownership,
367 news.answerInstructions,
368 );
369 const tags = news.tags ?? [];
370
371 let current = yield* getByName(output?.name ?? name);
372
373 if (current === undefined) {
374 const created = yield* cci
375 .createProjectsLocationsQaScorecardsRevisionsQaQuestions({
376 parent: news.parent,
377 qaQuestionId,
378 body: {
379 questionBody: news.questionBody,
380 abbreviation: news.abbreviation,
381 questionType: news.questionType,
382 answerInstructions,
383 order: news.order,
384 answerChoices: news.answerChoices,
385 tags: tags.length > 0 ? tags : undefined,
386 predefinedQuestionConfig: news.predefinedQuestionConfig,
387 qaQuestionDataOptions: news.qaQuestionDataOptions,
388 },
389 })
390 .pipe(
391 Effect.retry({
392 while: (error) => error._tag === "QaScorecardPreconditionFailed",
393 times: 8,
394 schedule: Schedule.exponential("250 millis"),
395 }),
396 Effect.catchIf(
397 (error) => error._tag === "Conflict",
398 () => getByName(name),
399 ),
400 );
401 current = created ?? undefined;
402 }
403
404 if (current === undefined) {
405 return yield* new QaScorecardsRevisionsQaQuestionNotResolved({
406 name,
407 });
408 }
409
410 const currentName = current.name ?? name;
411 const bodyChanged = !sameText(current.questionBody, news.questionBody);
412 const abbreviationChanged = !sameText(
413 current.abbreviation,
414 news.abbreviation,
415 );
416 const instructionsChanged =
417 (current.answerInstructions ?? "") !== answerInstructions;
418 const orderChanged = (current.order ?? 0) !== (news.order ?? 0);
419 const choicesChanged = !jsonEqual(
420 choicesOf(current.answerChoices),
421 news.answerChoices,
422 );
423 const tagsChanged = !jsonEqual(current.tags ?? [], tags);
424
425 if (
426 bodyChanged ||
427 abbreviationChanged ||
428 instructionsChanged ||
429 orderChanged ||
430 choicesChanged ||
431 tagsChanged
432 ) {
433 current =
434 yield* cci.patchProjectsLocationsQaScorecardsRevisionsQaQuestions({
435 name: currentName,
436 updateMask: updateMaskOf(
437 bodyChanged ? "question_body" : undefined,
438 abbreviationChanged ? "abbreviation" : undefined,
439 instructionsChanged ? "answer_instructions" : undefined,
440 orderChanged ? "order" : undefined,
441 choicesChanged ? "answer_choices" : undefined,
442 tagsChanged ? "tags" : undefined,
443 ),
444 body: {
445 name: currentName,
446 questionBody: news.questionBody,
447 abbreviation: news.abbreviation,
448 answerInstructions,
449 order: news.order,
450 answerChoices: news.answerChoices,
451 tags,
452 },
453 });
454 }
455
456 return toAttrs(current, env.project);
457 }),
458
459 delete: Effect.fn(function* ({ output }) {
460 yield* cci
461 .deleteProjectsLocationsQaScorecardsRevisionsQaQuestions({
462 name: output.name,
463 })
464 .pipe(Effect.catchTag("NotFound", () => Effect.void));
465 }),
466 });