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 { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import type { AnnotatorSelector } from "./AnalysisRule.ts";
12import { resourceNameFromOperation, waitForOperation } from "./operations.ts";
13import {
14 lastSegment,
15 locationOf,
16 locationParent,
17 parentOf,
18 sameJson,
19} from "./ownership.ts";
20
21type AnalysisAnnotatorSelector = AnnotatorSelector & {
22 /** Summarization annotator configuration. */
23 summarizationConfig?: cci.GoogleCloudContactcenterinsightsV1AnnotatorSelectorSummarizationConfig;
24 /** QA annotator configuration. */
25 qaConfig?: cci.GoogleCloudContactcenterinsightsV1AnnotatorSelectorQaConfig;
26};
27
28export type ConversationsAnalysesProps = {
29 /**
30 * Parent Conversation resource name
31 * (`projects/{project}/locations/{location}/conversations/{conversation}`).
32 * Immutable — changing it replaces the analysis.
33 */
34 parent: string;
35 /**
36 * Annotators to run. Analyses have no update RPC; changing the selector
37 * replaces the analysis. Analyses have no labels or description field —
38 * nuke finds them by walking alchemy-labeled parent conversations.
39 */
40 annotatorSelector?: AnalysisAnnotatorSelector;
41};
42
43export type ConversationsAnalyses = Resource<
44 "GCP.ContactCenterInsights.ConversationsAnalyses",
45 ConversationsAnalysesProps,
46 {
47 /** Full resource name. */
48 name: string;
49 /** Analysis id (last path segment). */
50 analysisId: string;
51 /** Parent conversation resource name. */
52 parent: string;
53 /** Region id. */
54 location: string;
55 /** Project id. */
56 project: string;
57 /** Annotators that were selected. */
58 annotatorSelector: AnalysisAnnotatorSelector | undefined;
59 /** RFC3339 time the analysis was requested. */
60 requestTime: string | undefined;
61 /** RFC3339 creation timestamp (when the LRO completed). */
62 createTime: string | undefined;
63 },
64 never,
65 Providers
66>;
67
68/**
69 * An analysis of a Contact Center Insights conversation.
70 *
71 * Parent conversation and annotator selector are immutable — there is no
72 * update RPC, so changing either replaces the analysis. Create is a
73 * long-running operation that completes when the analysis finishes.
74 * Analyses have no labels field; `list` walks alchemy-labeled parent
75 * conversations.
76 *
77 * ### Creating an Analysis
78 * **Example:** Silence-only analysis
79 * ```typescript
80 * const analysis = yield* GCP.ContactCenterInsights.ConversationsAnalyses(
81 * "Silence",
82 * {
83 * parent: conversation.name,
84 * annotatorSelector: { runSilenceAnnotator: true },
85 * },
86 * );
87 * ```
88 *
89 * @resource
90 * @category ContactCenterInsights
91 */
92export const ConversationsAnalyses = Resource<ConversationsAnalyses>(
93 "GCP.ContactCenterInsights.ConversationsAnalyses",
94);
95
96export class ConversationsAnalysesNotResolved extends Data.TaggedError(
97 "GCP.ContactCenterInsights.ConversationsAnalysesNotResolved",
98)<{
99 name: string;
100}> {}
101
102const toSelector = (
103 selector:
104 | cci.GoogleCloudContactcenterinsightsV1AnnotatorSelector
105 | AnalysisAnnotatorSelector
106 | undefined,
107): AnalysisAnnotatorSelector | undefined => {
108 if (selector === undefined) return undefined;
109 return {
110 runSentimentAnnotator: selector.runSentimentAnnotator,
111 runSummarizationAnnotator: selector.runSummarizationAnnotator,
112 runQaAnnotator: selector.runQaAnnotator,
113 runAutoLabelingAnnotator: selector.runAutoLabelingAnnotator,
114 runIssueModelAnnotator: selector.runIssueModelAnnotator,
115 runSilenceAnnotator: selector.runSilenceAnnotator,
116 runPhraseMatcherAnnotator: selector.runPhraseMatcherAnnotator,
117 runEntityAnnotator: selector.runEntityAnnotator,
118 runIntentAnnotator: selector.runIntentAnnotator,
119 runInterruptionAnnotator: selector.runInterruptionAnnotator,
120 issueModels: selector.issueModels,
121 phraseMatchers: selector.phraseMatchers,
122 summarizationConfig: selector.summarizationConfig,
123 qaConfig: selector.qaConfig,
124 };
125};
126
127const toAttrs = (
128 analysis: cci.GoogleCloudContactcenterinsightsV1Analysis,
129 project: string,
130) => {
131 const name = analysis.name ?? "";
132 return {
133 name,
134 analysisId: lastSegment(name),
135 parent: parentOf(name),
136 location: locationOf(name),
137 project,
138 annotatorSelector: toSelector(analysis.annotatorSelector),
139 requestTime: analysis.requestTime,
140 createTime: analysis.createTime,
141 };
142};
143
144const getByName = (name: string) =>
145 name.length === 0
146 ? Effect.succeed(undefined)
147 : cci
148 .getProjectsLocationsConversationsAnalyses({ name })
149 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
150
151const waitUntilExists = (name: string) =>
152 getByName(name).pipe(
153 Effect.flatMap((analysis) =>
154 analysis
155 ? Effect.succeed(analysis)
156 : Effect.fail(new ConversationsAnalysesNotResolved({ name })),
157 ),
158 Effect.retry({
159 while: (error) =>
160 error._tag ===
161 "GCP.ContactCenterInsights.ConversationsAnalysesNotResolved",
162 times: 8,
163 schedule: Schedule.spaced("2 seconds"),
164 }),
165 );
166
167const listConversations = (parent: string) =>
168 cci.listProjectsLocationsConversations
169 .pages({ parent, pageSize: 100, view: "BASIC" })
170 .pipe(
171 Stream.flatMap((page) => Stream.fromIterable(page.conversations ?? [])),
172 Stream.filter((conversation) =>
173 Object.keys(conversation.labels ?? {}).some((key) =>
174 key.startsWith("alchemy-"),
175 ),
176 ),
177 Stream.map((conversation) => conversation.name ?? ""),
178 Stream.filter((name) => name.length > 0),
179 Stream.runCollect,
180 Effect.map((chunk) => Array.from(chunk)),
181 Effect.catchTag("NotFound", () => Effect.succeed([] as string[])),
182 );
183
184const listAtParent = (parent: string, project: string) =>
185 cci.listProjectsLocationsConversationsAnalyses
186 .pages({ parent, pageSize: 100 })
187 .pipe(
188 Stream.flatMap((page) => Stream.fromIterable(page.analyses ?? [])),
189 Stream.map((analysis) => toAttrs(analysis, project)),
190 Stream.runCollect,
191 Effect.map((chunk) => Array.from(chunk)),
192 Effect.catchTag("NotFound", () => Effect.succeed([])),
193 );
194
195export const ConversationsAnalysesProvider = () =>
196 Provider.succeed(ConversationsAnalyses, {
197 stables: [
198 "name",
199 "analysisId",
200 "parent",
201 "location",
202 "project",
203 "createTime",
204 ],
205
206 diff: Effect.fn(function* ({ news, olds, output }) {
207 if (!isResolved(news)) return undefined;
208 const previousParent = olds?.parent ?? output?.parent;
209 if (previousParent !== undefined && news.parent !== previousParent) {
210 return { action: "replace" as const, deleteFirst: false };
211 }
212 const previousSelector =
213 olds?.annotatorSelector ?? output?.annotatorSelector;
214 if (
215 previousSelector !== undefined &&
216 !sameJson(
217 toSelector(previousSelector),
218 toSelector(news.annotatorSelector),
219 )
220 ) {
221 return { action: "replace" as const, deleteFirst: false };
222 }
223 return undefined;
224 }),
225
226 read: Effect.fn(function* ({ output }) {
227 const env = yield* GcpEnvironment.current;
228 const existing = yield* getByName(output?.name ?? "");
229 if (existing === undefined) return undefined;
230 return toAttrs(existing, env.project);
231 }),
232
233 list: () =>
234 Effect.gen(function* () {
235 const env = yield* GcpEnvironment.current;
236 const parent = locationParent(env.project, env.region);
237 const conversations = yield* listConversations(parent);
238 const pages = yield* Effect.forEach(
239 conversations,
240 (conversation) => listAtParent(conversation, env.project),
241 { concurrency: 4 },
242 );
243 return pages.flat();
244 }),
245
246 reconcile: Effect.fn(function* ({ news, output }) {
247 const env = yield* GcpEnvironment.current;
248 let current = yield* getByName(output?.name ?? "");
249
250 if (current === undefined) {
251 const created = yield* cci
252 .createProjectsLocationsConversationsAnalyses({
253 parent: news.parent,
254 body: {
255 annotatorSelector: news.annotatorSelector,
256 },
257 })
258 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
259 if (created !== undefined) {
260 const done = yield* waitForOperation(created);
261 const createdName = resourceNameFromOperation(done) ?? "";
262 if (createdName.length > 0) {
263 current = yield* waitUntilExists(createdName);
264 }
265 }
266 }
267
268 if (current === undefined) {
269 const listed = yield* listAtParent(news.parent, env.project);
270 current = listed[0] ? yield* getByName(listed[0].name) : undefined;
271 }
272
273 if (current === undefined) {
274 return yield* new ConversationsAnalysesNotResolved({
275 name: output?.name ?? `${news.parent}/analyses/-`,
276 });
277 }
278
279 return toAttrs(current, env.project);
280 }),
281
282 delete: Effect.fn(function* ({ output }) {
283 yield* cci
284 .deleteProjectsLocationsConversationsAnalyses({ name: output.name })
285 .pipe(Effect.catchTag("NotFound", () => Effect.void));
286 }),
287 });