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 { tagRecord } from "../../Tags.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import {
12 createInternalLabels,
13 diffLabels,
14 hasAlchemyLabels,
15 stripInternalLabels,
16 toLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19import {
20 lastSegment,
21 locationOf,
22 locationParent,
23 normalizeLocation,
24 sameText,
25 toResourceId,
26 updateMaskOf,
27} from "./ownership.ts";
28
29export type ConversationMedium = "MEDIUM_UNSPECIFIED" | "PHONE_CALL" | "CHAT";
30
31export type GcsSource = {
32 /** Cloud Storage URI of conversation audio. */
33 audioUri?: string;
34 /** Cloud Storage URI of the conversation transcript. Immutable. */
35 transcriptUri?: string;
36};
37
38export type ConversationDataSource = {
39 /** Cloud Storage audio and/or transcript. */
40 gcsSource?: GcsSource;
41 /** Cloud Storage URI of conversation metadata. */
42 metadataUri?: string;
43};
44
45export type CallMetadata = {
46 /** Audio channel that contains the customer. */
47 customerChannel?: number;
48 /** Audio channel that contains the agent. */
49 agentChannel?: number;
50};
51
52export type ConversationProps = {
53 /**
54 * Conversation id (the `{conversation}` segment of
55 * `projects/{project}/locations/{location}/conversations/{conversation}`).
56 * If omitted, a unique id is generated. Must match `^[a-z0-9-]{4,64}$`.
57 * Immutable — changing it replaces the conversation.
58 */
59 conversationId?: string;
60 /**
61 * Location (`us-central1`, …). Immutable — changing it replaces the
62 * conversation. `US-CENTRAL1` is accepted and normalized to
63 * `us-central1`.
64 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
65 */
66 location?: string;
67 /**
68 * Conversation medium. Immutable — changing it replaces the
69 * conversation.
70 * @default "CHAT"
71 */
72 medium?: ConversationMedium | (string & {});
73 /**
74 * User labels. Alchemy ownership labels are merged in automatically.
75 * At most 100 labels, 256 characters per entry.
76 */
77 labels?: Record<string, string>;
78 /** Opaque id of the human agent who handled the conversation. */
79 agentId?: string;
80 /** BCP-47 language code. */
81 languageCode?: string;
82 /** RFC3339 conversation start time. */
83 startTime?: string;
84 /**
85 * RFC3339 expiry. After this time the conversation and its analyses
86 * are deleted.
87 */
88 expireTime?: string;
89 /**
90 * Input-only TTL used to compute `expireTime` (e.g. `"86400s"`).
91 */
92 ttl?: string;
93 /** Audio and transcript source. */
94 dataSource?: ConversationDataSource;
95 /** Call-specific channel metadata. */
96 callMetadata?: CallMetadata;
97 /** Telephony-system JSON metadata. */
98 metadataJson?: string;
99 /** Obfuscated end-user id. */
100 obfuscatedUserId?: string;
101 /**
102 * Delete analyses together with the conversation.
103 * @default true
104 */
105 forceDestroy?: boolean;
106};
107
108export type Conversation = Resource<
109 "GCP.ContactCenterInsights.Conversation",
110 ConversationProps,
111 {
112 /** Full resource name `projects/{project}/locations/{location}/conversations/{conversation}`. */
113 name: string;
114 /** Conversation id (last path segment). */
115 conversationId: string;
116 /** Location id. */
117 location: string;
118 /** Project id. */
119 project: string;
120 /** Conversation medium. */
121 medium: string | undefined;
122 /** User labels (Alchemy ownership labels stripped). */
123 labels: Record<string, string>;
124 /** Agent id. */
125 agentId: string | undefined;
126 /** Language code. */
127 languageCode: string | undefined;
128 /** RFC3339 start time. */
129 startTime: string | undefined;
130 /** RFC3339 expiry. */
131 expireTime: string | undefined;
132 /** Data source. */
133 dataSource: ConversationDataSource | undefined;
134 /** Call metadata. */
135 callMetadata: CallMetadata | undefined;
136 /** Duration of the conversation. */
137 duration: string | undefined;
138 /** Number of turns. */
139 turnCount: number | undefined;
140 /** RFC3339 creation timestamp. */
141 createTime: string | undefined;
142 /** RFC3339 last-update timestamp. */
143 updateTime: string | undefined;
144 },
145 never,
146 Providers
147>;
148
149/**
150 * A Contact Center AI Insights conversation (chat or phone call).
151 *
152 * `create` does not transcribe or redact audio — use `conversations.upload`
153 * for that. Location, id, and medium are immutable. Labels, agent id,
154 * language, and expiry update in place.
155 *
156 * ### Creating a Conversation
157 * **Example:** Chat conversation with labels
158 * ```typescript
159 * const conversation = yield* GCP.ContactCenterInsights.Conversation(
160 * "Chat",
161 * {
162 * medium: "CHAT",
163 * languageCode: "en-US",
164 * agentId: "agent-1",
165 * labels: { env: "test" },
166 * },
167 * );
168 * ```
169 *
170 * **Example:** Transcript from Cloud Storage
171 * ```typescript
172 * const conversation = yield* GCP.ContactCenterInsights.Conversation(
173 * "Chat",
174 * {
175 * medium: "CHAT",
176 * dataSource: {
177 * gcsSource: { transcriptUri: "gs://bucket/transcript.json" },
178 * },
179 * },
180 * );
181 * ```
182 *
183 * @resource
184 * @category ContactCenterInsights
185 */
186export const Conversation = Resource<Conversation>(
187 "GCP.ContactCenterInsights.Conversation",
188);
189
190export class ConversationNotResolved extends Data.TaggedError(
191 "GCP.ContactCenterInsights.ConversationNotResolved",
192)<{
193 name: string;
194}> {}
195
196const DEFAULT_MEDIUM = "CHAT";
197
198const userLabels = (
199 labels: Record<string, string | undefined> | null | undefined,
200): Record<string, string> => stripInternalLabels(tagRecord(labels));
201
202const resourceName = (
203 project: string,
204 location: string,
205 conversationId: string,
206) => `${locationParent(project, location)}/conversations/${conversationId}`;
207
208const dataSourceOf = (
209 source:
210 | cci.GoogleCloudContactcenterinsightsV1ConversationDataSource
211 | undefined,
212): ConversationDataSource | undefined => {
213 if (source === undefined) return undefined;
214 return {
215 gcsSource: source.gcsSource
216 ? {
217 audioUri: source.gcsSource.audioUri,
218 transcriptUri: source.gcsSource.transcriptUri,
219 }
220 : undefined,
221 metadataUri: source.metadataUri,
222 };
223};
224
225const callMetadataOf = (
226 metadata:
227 | cci.GoogleCloudContactcenterinsightsV1ConversationCallMetadata
228 | undefined,
229): CallMetadata | undefined => {
230 if (metadata === undefined) return undefined;
231 return {
232 customerChannel: metadata.customerChannel,
233 agentChannel: metadata.agentChannel,
234 };
235};
236
237const toAttrs = (
238 conversation: cci.GoogleCloudContactcenterinsightsV1Conversation,
239 project: string,
240) => {
241 const name = conversation.name ?? "";
242 return {
243 name,
244 conversationId: lastSegment(name),
245 location: locationOf(name),
246 project,
247 medium: conversation.medium,
248 labels: userLabels(conversation.labels),
249 agentId: conversation.agentId,
250 languageCode: conversation.languageCode,
251 startTime: conversation.startTime,
252 expireTime: conversation.expireTime,
253 dataSource: dataSourceOf(conversation.dataSource),
254 callMetadata: callMetadataOf(conversation.callMetadata),
255 duration: conversation.duration,
256 turnCount: conversation.turnCount,
257 createTime: conversation.createTime,
258 updateTime: conversation.updateTime,
259 };
260};
261
262const getByName = (name: string) =>
263 name.length === 0
264 ? Effect.succeed(undefined)
265 : cci
266 .getProjectsLocationsConversations({ name, view: "BASIC" })
267 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
268
269const listAt = (parent: string, project: string) =>
270 cci.listProjectsLocationsConversations
271 .pages({ parent, pageSize: 100, view: "BASIC" })
272 .pipe(
273 Stream.flatMap((page) => Stream.fromIterable(page.conversations ?? [])),
274 Stream.filter((conversation) =>
275 Object.keys(conversation.labels ?? {}).some((key) =>
276 key.startsWith("alchemy-"),
277 ),
278 ),
279 Stream.map((conversation) => toAttrs(conversation, project)),
280 Stream.runCollect,
281 Effect.map((chunk) => Array.from(chunk)),
282 Effect.catchTag("NotFound", () => Effect.succeed([])),
283 );
284
285export const ConversationProvider = () =>
286 Provider.succeed(Conversation, {
287 stables: [
288 "name",
289 "conversationId",
290 "location",
291 "project",
292 "medium",
293 "createTime",
294 ],
295
296 diff: Effect.fn(function* ({ news, olds, output }) {
297 if (!isResolved(news)) return undefined;
298 const previousLocation = olds?.location ?? output?.location;
299 if (
300 previousLocation !== undefined &&
301 news.location !== undefined &&
302 normalizeLocation(previousLocation) !== normalizeLocation(news.location)
303 ) {
304 return { action: "replace" as const, deleteFirst: false };
305 }
306 const previousId = olds?.conversationId ?? output?.conversationId;
307 if (
308 previousId !== undefined &&
309 news.conversationId !== undefined &&
310 news.conversationId !== previousId
311 ) {
312 return { action: "replace" as const, deleteFirst: false };
313 }
314 const previousMedium = olds?.medium ?? output?.medium;
315 const nextMedium = news.medium ?? DEFAULT_MEDIUM;
316 if (previousMedium !== undefined && previousMedium !== nextMedium) {
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 conversationId = yield* toResourceId(
325 id,
326 olds?.conversationId,
327 output?.conversationId,
328 );
329 const location = normalizeLocation(
330 olds?.location ?? output?.location ?? env.region,
331 );
332 const name =
333 output?.name ?? resourceName(env.project, location, conversationId);
334 const existing = yield* getByName(name);
335 if (existing === undefined) return undefined;
336 const attrs = toAttrs(existing, env.project);
337 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
338 ? attrs
339 : Unowned(attrs);
340 }),
341
342 list: () =>
343 Effect.gen(function* () {
344 const env = yield* GcpEnvironment.current;
345 return yield* listAt(
346 locationParent(env.project, env.region),
347 env.project,
348 );
349 }),
350
351 reconcile: Effect.fn(function* ({ id, news, output }) {
352 const env = yield* GcpEnvironment.current;
353 const location = normalizeLocation(
354 news.location ?? output?.location ?? env.region,
355 );
356 const parent = locationParent(env.project, location);
357 const conversationId = yield* toResourceId(
358 id,
359 news.conversationId,
360 output?.conversationId,
361 );
362 const name = resourceName(env.project, location, conversationId);
363 const desiredLabels = {
364 ...toLabels(news.labels),
365 ...(yield* createInternalLabels(id)),
366 };
367 const medium = news.medium ?? DEFAULT_MEDIUM;
368
369 let current = yield* getByName(output?.name ?? name);
370
371 if (current === undefined) {
372 const created = yield* cci
373 .createProjectsLocationsConversations({
374 parent,
375 conversationId,
376 body: {
377 medium,
378 labels: desiredLabels,
379 agentId: news.agentId,
380 languageCode: news.languageCode,
381 startTime: news.startTime,
382 expireTime: news.expireTime,
383 ttl: news.ttl,
384 dataSource: news.dataSource,
385 callMetadata: news.callMetadata,
386 metadataJson: news.metadataJson,
387 obfuscatedUserId: news.obfuscatedUserId,
388 },
389 })
390 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
391 current = created ?? undefined;
392 }
393
394 if (current === undefined) {
395 return yield* new ConversationNotResolved({ name });
396 }
397
398 const currentName = current.name ?? name;
399 const observedLabels = tagRecord(current.labels);
400 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
401 const labelsChanged = upsert.length > 0 || removed.length > 0;
402 const agentChanged = !sameText(current.agentId, news.agentId);
403 const languageChanged = !sameText(
404 current.languageCode,
405 news.languageCode,
406 );
407 // Insights assigns a start time when none is given; only a
408 // caller-supplied one can be updated.
409 const startChanged =
410 news.startTime !== undefined &&
411 !sameText(current.startTime, news.startTime);
412 const expireChanged =
413 news.expireTime !== undefined &&
414 !sameText(current.expireTime, news.expireTime);
415 const callChanged =
416 news.callMetadata !== undefined &&
417 ((current.callMetadata?.customerChannel ?? undefined) !==
418 news.callMetadata.customerChannel ||
419 (current.callMetadata?.agentChannel ?? undefined) !==
420 news.callMetadata.agentChannel);
421
422 if (
423 labelsChanged ||
424 agentChanged ||
425 languageChanged ||
426 startChanged ||
427 expireChanged ||
428 callChanged
429 ) {
430 current = yield* cci.patchProjectsLocationsConversations({
431 name: currentName,
432 updateMask: updateMaskOf(
433 labelsChanged ? "labels" : undefined,
434 agentChanged ? "agent_id" : undefined,
435 languageChanged ? "language_code" : undefined,
436 startChanged ? "start_time" : undefined,
437 expireChanged ? "expire_time" : undefined,
438 callChanged ? "call_metadata" : undefined,
439 ),
440 body: {
441 name: currentName,
442 labels: desiredLabels,
443 agentId: news.agentId,
444 languageCode: news.languageCode,
445 startTime: news.startTime,
446 expireTime: news.expireTime,
447 callMetadata: news.callMetadata,
448 },
449 });
450 }
451
452 return toAttrs(current, env.project);
453 }),
454
455 delete: Effect.fn(function* ({ output, olds }) {
456 yield* cci
457 .deleteProjectsLocationsConversations({
458 name: output.name,
459 force: olds?.forceDestroy !== false,
460 })
461 .pipe(Effect.catchTag("NotFound", () => Effect.void));
462 }),
463 });