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 { 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 { resourceNameFromOperation, waitForOperation } from "./operations.ts";
13import {
14 encodeOwnershipLine,
15 hasOwnershipMarker,
16 lastSegment,
17 locationOf,
18 locationParent,
19 ownedByAlchemy,
20 parseOwnership,
21 sameJson,
22} from "./ownership.ts";
23
24export type IssueModelInputDataConfig = {
25 /**
26 * Medium of training conversations (`PHONE_CALL` or `CHAT`). Deprecated
27 * in favor of `filter`.
28 */
29 medium?: "MEDIUM_UNSPECIFIED" | "PHONE_CALL" | "CHAT";
30 /**
31 * Filter reducing conversations used to train the model.
32 */
33 filter?: string;
34};
35
36export type IssueModelProps = {
37 /**
38 * Region (`us-central1`, …). Immutable — changing it replaces the model.
39 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
40 */
41 location?: string;
42 /**
43 * Representative name. Issue models have no labels field, so Alchemy
44 * ownership is stored in a `[alchemy …]` prefix and stripped from
45 * attributes.
46 */
47 displayName?: string;
48 /**
49 * Model language (`en-US`, …).
50 */
51 languageCode?: string;
52 /**
53 * Model type (`TYPE_V1` or `TYPE_V2`).
54 */
55 modelType?: "MODEL_TYPE_UNSPECIFIED" | "TYPE_V1" | "TYPE_V2";
56 /**
57 * Training-data configuration.
58 */
59 inputDataConfig?: IssueModelInputDataConfig;
60};
61
62export type IssueModel = Resource<
63 "GCP.ContactCenterInsights.IssueModel",
64 IssueModelProps,
65 {
66 /** Full resource name. */
67 name: string;
68 /** Issue model id (last path segment). */
69 issueModelId: string;
70 /** Region id. */
71 location: string;
72 /** Project id. */
73 project: string;
74 /** User display name with the Alchemy ownership prefix stripped. */
75 displayName: string | undefined;
76 /** Model language. */
77 languageCode: string | undefined;
78 /** Model type. */
79 modelType: string | undefined;
80 /** Training-data configuration. */
81 inputDataConfig: IssueModelInputDataConfig | undefined;
82 /** Server-reported model state. */
83 state: string | undefined;
84 /** Number of issues. */
85 issueCount: string | undefined;
86 /** RFC3339 creation timestamp. */
87 createTime: string | undefined;
88 /** RFC3339 last-update timestamp. */
89 updateTime: string | undefined;
90 },
91 never,
92 Providers
93>;
94
95/**
96 * A Contact Center Insights issue model for topic modeling.
97 *
98 * Issue models have no labels field — Alchemy stamps ownership into the
99 * display name. Location is immutable. Display name, language, model type,
100 * and input data config update in place. Create and delete are
101 * long-running; a deployed model is undeployed before delete.
102 *
103 * ### Creating an Issue Model
104 * **Example:** V2 model over chat transcripts
105 * ```typescript
106 * const model = yield* GCP.ContactCenterInsights.IssueModel("Topics", {
107 * displayName: "billing-topics",
108 * modelType: "TYPE_V2",
109 * inputDataConfig: { medium: "CHAT" },
110 * });
111 * ```
112 *
113 * @resource
114 * @category ContactCenterInsights
115 */
116export const IssueModel = Resource<IssueModel>(
117 "GCP.ContactCenterInsights.IssueModel",
118);
119
120export class IssueModelNotResolved extends Data.TaggedError(
121 "GCP.ContactCenterInsights.IssueModelNotResolved",
122)<{
123 name: string;
124}> {}
125
126const toAttrs = (
127 model: cci.GoogleCloudContactcenterinsightsV1IssueModel,
128 project: string,
129) => {
130 const name = model.name ?? "";
131 const parsed = parseOwnership(model.displayName);
132 return {
133 name,
134 issueModelId: lastSegment(name),
135 location: locationOf(name),
136 project,
137 displayName: parsed.text,
138 languageCode: model.languageCode,
139 modelType: model.modelType,
140 inputDataConfig: model.inputDataConfig
141 ? {
142 medium: model.inputDataConfig.medium as
143 | IssueModelInputDataConfig["medium"]
144 | undefined,
145 filter: model.inputDataConfig.filter,
146 }
147 : undefined,
148 state: model.state,
149 issueCount: model.issueCount,
150 createTime: model.createTime,
151 updateTime: model.updateTime,
152 };
153};
154
155const getByName = (name: string) =>
156 name.length === 0
157 ? Effect.succeed(undefined)
158 : cci
159 .getProjectsLocationsIssueModels({ name })
160 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
161
162const waitUntilExists = (name: string) =>
163 getByName(name).pipe(
164 Effect.flatMap((model) =>
165 model
166 ? Effect.succeed(model)
167 : Effect.fail(new IssueModelNotResolved({ name })),
168 ),
169 Effect.retry({
170 while: (error) =>
171 error._tag === "GCP.ContactCenterInsights.IssueModelNotResolved",
172 times: 8,
173 schedule: Schedule.spaced("2 seconds"),
174 }),
175 );
176
177const listAt = (parent: string, project: string) =>
178 cci.listProjectsLocationsIssueModels({ parent }).pipe(
179 Effect.map((page) =>
180 (page.issueModels ?? [])
181 .filter((model) => hasOwnershipMarker(model.displayName))
182 .map((model) => toAttrs(model, project)),
183 ),
184 Effect.catchTag("NotFound", () => Effect.succeed([])),
185 );
186
187const findByDisplayName = (parent: string, displayName: string) =>
188 cci.listProjectsLocationsIssueModels({ parent }).pipe(
189 Effect.map((page) =>
190 (page.issueModels ?? []).find(
191 (model) => model.displayName === displayName,
192 ),
193 ),
194 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
195 );
196
197export const IssueModelProvider = () =>
198 Provider.succeed(IssueModel, {
199 stables: ["name", "issueModelId", "location", "project", "createTime"],
200
201 diff: Effect.fn(function* ({ news, olds, output }) {
202 if (!isResolved(news)) return undefined;
203 const previousLocation = olds?.location ?? output?.location;
204 const nextLocation = news.location;
205 if (
206 previousLocation !== undefined &&
207 nextLocation !== undefined &&
208 previousLocation !== nextLocation
209 ) {
210 return { action: "replace" as const, deleteFirst: false };
211 }
212 return undefined;
213 }),
214
215 read: Effect.fn(function* ({ id, olds, output }) {
216 const env = yield* GcpEnvironment.current;
217 const existing = yield* getByName(output?.name ?? "");
218 if (existing !== undefined) {
219 const attrs = toAttrs(existing, env.project);
220 return (yield* ownedByAlchemy(id, existing.displayName))
221 ? attrs
222 : Unowned(attrs);
223 }
224 const location = olds?.location ?? env.region;
225 const parent = locationParent(env.project, location);
226 const ownership = yield* createInternalLabels(id);
227 const displayName = encodeOwnershipLine(ownership, olds?.displayName);
228 const found = yield* findByDisplayName(parent, displayName);
229 if (found === undefined) return undefined;
230 const attrs = toAttrs(found, env.project);
231 return (yield* ownedByAlchemy(id, found.displayName))
232 ? attrs
233 : Unowned(attrs);
234 }),
235
236 list: () =>
237 Effect.gen(function* () {
238 const env = yield* GcpEnvironment.current;
239 return yield* listAt(
240 locationParent(env.project, env.region),
241 env.project,
242 );
243 }),
244
245 reconcile: Effect.fn(function* ({ id, news, output }) {
246 const env = yield* GcpEnvironment.current;
247 const location = news.location ?? output?.location ?? env.region;
248 const parent = locationParent(env.project, location);
249 const ownership = yield* createInternalLabels(id);
250 const displayName = encodeOwnershipLine(ownership, news.displayName);
251
252 let current = yield* getByName(output?.name ?? "");
253 if (current === undefined) {
254 current = yield* findByDisplayName(parent, displayName);
255 }
256
257 if (current === undefined) {
258 const created = yield* cci
259 .createProjectsLocationsIssueModels({
260 parent,
261 body: {
262 displayName,
263 languageCode: news.languageCode,
264 modelType: news.modelType,
265 inputDataConfig: news.inputDataConfig,
266 },
267 })
268 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
269 if (created !== undefined) {
270 const done = yield* waitForOperation(created);
271 const createdName = resourceNameFromOperation(done) ?? "";
272 if (createdName.length > 0) {
273 current = yield* waitUntilExists(createdName);
274 }
275 }
276 if (current === undefined) {
277 current = yield* findByDisplayName(parent, displayName);
278 }
279 }
280
281 if (current === undefined) {
282 return yield* new IssueModelNotResolved({
283 name: output?.name ?? `${parent}/issueModels/-`,
284 });
285 }
286
287 const name = current.name ?? "";
288 const displayChanged = (current.displayName ?? "") !== displayName;
289 const languageChanged =
290 (current.languageCode ?? "") !== (news.languageCode ?? "");
291 const typeChanged = (current.modelType ?? "") !== (news.modelType ?? "");
292 const inputChanged = !sameJson(
293 current.inputDataConfig
294 ? {
295 medium: current.inputDataConfig.medium,
296 filter: current.inputDataConfig.filter,
297 }
298 : undefined,
299 news.inputDataConfig,
300 );
301
302 if (displayChanged || languageChanged || typeChanged || inputChanged) {
303 current = yield* cci.patchProjectsLocationsIssueModels({
304 name,
305 updateMask: [
306 displayChanged ? "display_name" : undefined,
307 languageChanged ? "language_code" : undefined,
308 typeChanged ? "model_type" : undefined,
309 inputChanged ? "input_data_config" : undefined,
310 ]
311 .filter((field): field is string => field !== undefined)
312 .join(","),
313 body: {
314 name,
315 displayName,
316 languageCode: news.languageCode,
317 modelType: news.modelType,
318 inputDataConfig: news.inputDataConfig,
319 },
320 });
321 }
322
323 return toAttrs(current, env.project);
324 }),
325
326 delete: Effect.fn(function* ({ output }) {
327 const existing = yield* getByName(output.name);
328 if (existing === undefined) return;
329 if (existing.state === "DEPLOYED" || existing.state === "DEPLOYING") {
330 const undeploy = yield* cci
331 .undeployProjectsLocationsIssueModels({
332 name: output.name,
333 body: { name: output.name },
334 })
335 .pipe(
336 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
337 Effect.catchTag("BadRequest", () => Effect.succeed(undefined)),
338 );
339 if (undeploy !== undefined) {
340 yield* waitForOperation(undeploy, { notFoundOk: true });
341 }
342 }
343 const operation = yield* cci
344 .deleteProjectsLocationsIssueModels({ name: output.name })
345 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
346 if (operation !== undefined) {
347 yield* waitForOperation(operation, { notFoundOk: true });
348 }
349 }),
350 });