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 lastSegment,
16 locationOf,
17 locationParent,
18 ownedByAlchemy,
19 parentOf,
20 parseOwnership,
21 sameJson,
22} from "./ownership.ts";
23
24type AgentInfo = {
25 /** User-specified agent identifier. */
26 agentId?: string;
27 /**
28 * Agent display name. Assessments have no labels field, so Alchemy
29 * ownership is stored in a `[alchemy …]` prefix on this field and
30 * stripped from attributes.
31 */
32 displayName?: string;
33 /** Agent type (`HUMAN_AGENT`, `AUTOMATED_AGENT`, …). */
34 agentType?:
35 | "ROLE_UNSPECIFIED"
36 | "HUMAN_AGENT"
37 | "AUTOMATED_AGENT"
38 | "END_USER"
39 | "ANY_AGENT";
40 /** Deprecated team string. Prefer `teams`. */
41 team?: string;
42 /** Team names. */
43 teams?: string[];
44 /** Agent location. */
45 location?: string;
46 /** Outcome of the agent's segment of the call. */
47 dispositionCode?: string;
48 /** Automated-agent deployment id. */
49 deploymentId?: string;
50 /** Automated-agent deployment display name. */
51 deploymentDisplayName?: string;
52 /** Automated-agent version id. */
53 versionId?: string;
54 /** Automated-agent version display name. */
55 versionDisplayName?: string;
56 /** Entry subagent id. */
57 entrySubagentId?: string;
58 /** Entry subagent display name. */
59 entrySubagentDisplayName?: string;
60};
61
62export type ConversationsAssessmentProps = {
63 /**
64 * Parent Conversation resource name
65 * (`projects/{project}/locations/{location}/conversations/{conversation}`).
66 * Immutable — changing it replaces the assessment.
67 */
68 parent: string;
69 /**
70 * Agent the assessment is for. Assessments have no update RPC; changing
71 * agent info replaces the assessment.
72 */
73 agentInfo?: AgentInfo;
74};
75
76export type ConversationsAssessment = Resource<
77 "GCP.ContactCenterInsights.ConversationsAssessment",
78 ConversationsAssessmentProps,
79 {
80 /** Full resource name. */
81 name: string;
82 /** Assessment id (last path segment). */
83 assessmentId: string;
84 /** Parent conversation resource name. */
85 parent: string;
86 /** Region id. */
87 location: string;
88 /** Project id. */
89 project: string;
90 /** Agent info with the Alchemy ownership prefix stripped from displayName. */
91 agentInfo: AgentInfo | undefined;
92 /** Server-reported assessment state. */
93 state: string | undefined;
94 /** RFC3339 creation timestamp. */
95 createTime: string | undefined;
96 /** RFC3339 last-update timestamp. */
97 updateTime: string | undefined;
98 },
99 never,
100 Providers
101>;
102
103/**
104 * A quality assessment of a Contact Center Insights conversation.
105 *
106 * Parent conversation and agent info are immutable — there is no update
107 * RPC, so changing either replaces the assessment. Assessments have no
108 * labels field; Alchemy stamps ownership into `agentInfo.displayName`.
109 *
110 * ### Creating an Assessment
111 * **Example:** Human-agent assessment
112 * ```typescript
113 * const assessment = yield* GCP.ContactCenterInsights.ConversationsAssessment(
114 * "QA",
115 * {
116 * parent: conversation.name,
117 * agentInfo: { agentId: "agent-1", displayName: "Ada", agentType: "HUMAN_AGENT" },
118 * },
119 * );
120 * ```
121 *
122 * @resource
123 * @category ContactCenterInsights
124 */
125export const ConversationsAssessment = Resource<ConversationsAssessment>(
126 "GCP.ContactCenterInsights.ConversationsAssessment",
127);
128
129export class ConversationsAssessmentNotResolved extends Data.TaggedError(
130 "GCP.ContactCenterInsights.ConversationsAssessmentNotResolved",
131)<{
132 name: string;
133}> {}
134
135const toAgentInfo = (
136 info:
137 | cci.GoogleCloudContactcenterinsightsV1ConversationQualityMetadataAgentInfo
138 | AgentInfo
139 | undefined,
140 stripOwnership: boolean,
141): AgentInfo | undefined => {
142 if (info === undefined) return undefined;
143 const displayName = stripOwnership
144 ? parseOwnership(info.displayName).text
145 : info.displayName;
146 return {
147 agentId: info.agentId,
148 displayName,
149 agentType: info.agentType as AgentInfo["agentType"],
150 team: info.team,
151 teams: info.teams,
152 location: info.location,
153 dispositionCode: info.dispositionCode,
154 deploymentId: info.deploymentId,
155 deploymentDisplayName: info.deploymentDisplayName,
156 versionId: info.versionId,
157 versionDisplayName: info.versionDisplayName,
158 entrySubagentId: info.entrySubagentId,
159 entrySubagentDisplayName: info.entrySubagentDisplayName,
160 };
161};
162
163const encodeAgentInfo = (
164 info: AgentInfo | undefined,
165 ownership: Record<string, string>,
166): cci.GoogleCloudContactcenterinsightsV1ConversationQualityMetadataAgentInfo => ({
167 ...(info ?? {}),
168 displayName: encodeOwnershipLine(ownership, info?.displayName, 256),
169});
170
171const toAttrs = (
172 assessment: cci.GoogleCloudContactcenterinsightsV1Assessment,
173 project: string,
174) => {
175 const name = assessment.name ?? "";
176 return {
177 name,
178 assessmentId: lastSegment(name),
179 parent: parentOf(name),
180 location: locationOf(name),
181 project,
182 agentInfo: toAgentInfo(assessment.agentInfo, true),
183 state: assessment.state,
184 createTime: assessment.createTime,
185 updateTime: assessment.updateTime,
186 };
187};
188
189const getByName = (name: string) =>
190 name.length === 0
191 ? Effect.succeed(undefined)
192 : cci
193 .getProjectsLocationsConversationsAssessments({ name })
194 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
195
196const listAtParent = (parent: string, project: string) =>
197 cci.listProjectsLocationsConversationsAssessments
198 .pages({ parent, pageSize: 100 })
199 .pipe(
200 Stream.flatMap((page) => Stream.fromIterable(page.assessments ?? [])),
201 Stream.filter((assessment) =>
202 hasOwnershipMarker(assessment.agentInfo?.displayName),
203 ),
204 Stream.map((assessment) => toAttrs(assessment, project)),
205 Stream.runCollect,
206 Effect.map((chunk) => Array.from(chunk)),
207 Effect.catchTag("NotFound", () => Effect.succeed([])),
208 );
209
210const findOwned = (parent: string, displayName: string) =>
211 cci.listProjectsLocationsConversationsAssessments
212 .pages({ parent, pageSize: 100 })
213 .pipe(
214 Stream.flatMap((page) => Stream.fromIterable(page.assessments ?? [])),
215 Stream.filter(
216 (assessment) => assessment.agentInfo?.displayName === displayName,
217 ),
218 Stream.runHead,
219 Effect.map((option) =>
220 option._tag === "Some" ? option.value : undefined,
221 ),
222 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
223 );
224
225export const ConversationsAssessmentProvider = () =>
226 Provider.succeed(ConversationsAssessment, {
227 stables: [
228 "name",
229 "assessmentId",
230 "parent",
231 "location",
232 "project",
233 "createTime",
234 ],
235
236 diff: Effect.fn(function* ({ news, olds, output }) {
237 if (!isResolved(news)) return undefined;
238 const previousParent = olds?.parent ?? output?.parent;
239 if (previousParent !== undefined && news.parent !== previousParent) {
240 return { action: "replace" as const, deleteFirst: false };
241 }
242 const previousAgent = olds?.agentInfo ?? output?.agentInfo;
243 if (
244 previousAgent !== undefined &&
245 !sameJson(
246 toAgentInfo(previousAgent, false),
247 toAgentInfo(news.agentInfo, false),
248 )
249 ) {
250 return { action: "replace" as const, deleteFirst: false };
251 }
252 return undefined;
253 }),
254
255 read: Effect.fn(function* ({ id, olds, output }) {
256 const env = yield* GcpEnvironment.current;
257 const existing = yield* getByName(output?.name ?? "");
258 if (existing !== undefined) {
259 const attrs = toAttrs(existing, env.project);
260 return (yield* ownedByAlchemy(id, existing.agentInfo?.displayName))
261 ? attrs
262 : Unowned(attrs);
263 }
264 if (olds?.parent === undefined) return undefined;
265 const ownership = yield* createInternalLabels(id);
266 const displayName = encodeOwnershipLine(
267 ownership,
268 olds.agentInfo?.displayName,
269 );
270 const found = yield* findOwned(olds.parent, displayName);
271 if (found === undefined) return undefined;
272 const attrs = toAttrs(found, env.project);
273 return (yield* ownedByAlchemy(id, found.agentInfo?.displayName))
274 ? attrs
275 : Unowned(attrs);
276 }),
277
278 list: () =>
279 Effect.gen(function* () {
280 const env = yield* GcpEnvironment.current;
281 return yield* listAtParent(
282 `${locationParent(env.project, env.region)}/conversations/-`,
283 env.project,
284 );
285 }),
286
287 reconcile: Effect.fn(function* ({ id, news, output }) {
288 const env = yield* GcpEnvironment.current;
289 const ownership = yield* createInternalLabels(id);
290 const desiredAgent = encodeAgentInfo(news.agentInfo, ownership);
291 const displayName = desiredAgent.displayName ?? "";
292
293 let current = yield* getByName(output?.name ?? "");
294 if (current === undefined) {
295 current = yield* findOwned(news.parent, displayName);
296 }
297
298 if (current === undefined) {
299 const created = yield* cci
300 .createProjectsLocationsConversationsAssessments({
301 parent: news.parent,
302 body: { agentInfo: desiredAgent },
303 })
304 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
305 current = created ?? (yield* findOwned(news.parent, displayName));
306 }
307
308 if (current === undefined) {
309 return yield* new ConversationsAssessmentNotResolved({
310 name: output?.name ?? `${news.parent}/assessments/-`,
311 });
312 }
313
314 return toAttrs(current, env.project);
315 }),
316
317 delete: Effect.fn(function* ({ output }) {
318 yield* cci
319 .deleteProjectsLocationsConversationsAssessments({
320 name: output.name,
321 force: true,
322 })
323 .pipe(Effect.catchTag("NotFound", () => Effect.void));
324 }),
325 });