1import * as speech from "@distilled.cloud/gcp/speech_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 { createPhysicalName } from "../../PhysicalName.ts";
7import { alchemyLabelKeys } from "../Labels.ts";
8
9/** Speech-to-Text v1 Adaptation is served from the global endpoint. */
10export const DEFAULT_LOCATION = "global";
11/** The global Speech endpoint only serves the `global` location. */
12export const LIST_LOCATIONS = ["global"] as const;
13export const MAX_ID_LENGTH = 63;
14export const MAX_ITEM_LENGTH = 100;
15
16export class ResourceNotResolved extends Data.TaggedError(
17 "GCP.Speech.ResourceNotResolved",
18)<{
19 name: string;
20}> {}
21
22export const parseOwnershipMarker = (
23 text: string | undefined,
24): {
25 labels: Record<string, string>;
26 text: string | undefined;
27} => {
28 const value = text?.trim() ?? "";
29 if (!value.startsWith("alc ")) {
30 return { labels: {}, text };
31 }
32 const parts = value.slice("alc ".length).trim().split(/\s+/);
33 const labels: Record<string, string> = {};
34 if (parts[0]) labels[alchemyLabelKeys.stack] = parts[0]!;
35 if (parts[1]) labels[alchemyLabelKeys.stage] = parts[1]!;
36 if (parts[2]) labels[alchemyLabelKeys.id] = parts[2]!;
37 return { labels, text: undefined };
38};
39
40export const isOwnershipMarker = (text: string | undefined) =>
41 (text ?? "").trim().startsWith("alc ");
42
43export const hasOwnershipMarker = (text: string | undefined) => {
44 const { labels } = parseOwnershipMarker(text);
45 return Object.keys(labels).some((key) => key.startsWith("alchemy-"));
46};
47
48export const lastSegment = (value: string) => {
49 const trimmed = value.replace(/\/+$/, "");
50 const parts = trimmed.split("/");
51 return parts[parts.length - 1] || trimmed;
52};
53
54export const normalizeLocation = (location: string | undefined) =>
55 lastSegment(location ?? DEFAULT_LOCATION).toLowerCase();
56
57export const locationParent = (project: string, location: string | undefined) =>
58 `projects/${project}/locations/${normalizeLocation(location)}`;
59
60export const parseResourceName = (name: string, collection: string) => {
61 const parts = name.split("/").filter((part) => part.length > 0);
62 const collectionAt = parts.lastIndexOf(collection);
63 const locationsAt = parts.lastIndexOf("locations");
64 const projectsAt = parts.lastIndexOf("projects");
65 return {
66 project:
67 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
68 location:
69 locationsAt >= 0 && parts[locationsAt + 1]
70 ? parts[locationsAt + 1]!
71 : DEFAULT_LOCATION,
72 id:
73 collectionAt >= 0 && parts[collectionAt + 1]
74 ? parts[collectionAt + 1]!
75 : lastSegment(name),
76 };
77};
78
79export const resourceNameOf = (
80 project: string,
81 location: string,
82 collection: string,
83 id: string,
84) => {
85 if (id.length === 0) return "";
86 if (id.includes(`/${collection}/`)) return id.replace(/\/+$/, "");
87 return `${locationParent(project, location)}/${collection}/${lastSegment(id)}`;
88};
89
90export const sameText = (left: string | undefined, right: string | undefined) =>
91 (left ?? "") === (right ?? "");
92
93export const sameNumber = (
94 left: number | undefined,
95 right: number | undefined,
96) => (left ?? 0) === (right ?? 0);
97
98export const canonical = (value: unknown): unknown => {
99 if (value === undefined || value === null) return undefined;
100 if (typeof value === "boolean" || typeof value === "number") return value;
101 if (typeof value === "string") {
102 return value.length === 0 ? undefined : value;
103 }
104 if (Array.isArray(value)) {
105 const items = value.map(canonical).filter((item) => item !== undefined);
106 return items.length === 0 ? undefined : items;
107 }
108 if (typeof value === "object") {
109 const entries = Object.entries(value as Record<string, unknown>)
110 .map(([key, item]) => [key, canonical(item)] as const)
111 .filter(([, item]) => item !== undefined)
112 .sort(([left], [right]) => left.localeCompare(right));
113 if (entries.length === 0) return undefined;
114 return Object.fromEntries(entries);
115 }
116 return undefined;
117};
118
119export const fingerprint = (value: unknown): string =>
120 JSON.stringify(canonical(value) ?? null);
121
122export const sameJson = (left: unknown, right: unknown) =>
123 fingerprint(left) === fingerprint(right);
124
125export const updateMaskOf = (...fields: Array<string | undefined>) =>
126 fields.filter((field): field is string => field !== undefined).join(",");
127
128export const replaceOnIdentity = (input: {
129 previousId?: string;
130 nextId?: string;
131 previousLocation?: string;
132 nextLocation?: string;
133}) => {
134 const previousLocation =
135 input.previousLocation !== undefined
136 ? normalizeLocation(input.previousLocation)
137 : undefined;
138 const nextLocation =
139 input.nextLocation !== undefined
140 ? normalizeLocation(input.nextLocation)
141 : undefined;
142 const replace =
143 (input.previousId !== undefined &&
144 input.nextId !== undefined &&
145 input.nextId !== input.previousId) ||
146 (previousLocation !== undefined &&
147 nextLocation !== undefined &&
148 previousLocation !== nextLocation);
149 if (!replace) return undefined;
150 const samePhysical =
151 (previousLocation === undefined || previousLocation === nextLocation) &&
152 input.previousId !== undefined &&
153 input.nextId === input.previousId;
154 return {
155 action: "replace" as const,
156 deleteFirst: samePhysical,
157 };
158};
159
160export const toPhysicalId = (
161 id: string,
162 explicit: string | undefined,
163 existing: string | undefined,
164) =>
165 Effect.gen(function* () {
166 if (explicit !== undefined && explicit.length > 0) {
167 return lastSegment(explicit);
168 }
169 if (existing !== undefined && existing.length > 0) {
170 return existing;
171 }
172 const generated = yield* createPhysicalName({
173 id,
174 maxLength: MAX_ID_LENGTH,
175 lowercase: true,
176 });
177 let next = generated.replace(/-+$/g, "").replace(/^-+/g, "");
178 if (!/^[a-z]/.test(next)) {
179 next = `s${next}`.slice(0, MAX_ID_LENGTH);
180 }
181 if (next.length < 4) {
182 next = `${next}xxxx`.slice(0, MAX_ID_LENGTH);
183 }
184 if (!/[a-z0-9]$/.test(next)) {
185 next = `${next.slice(0, Math.max(0, MAX_ID_LENGTH - 1))}x`;
186 }
187 return next;
188 });
189
190export type ClassItem = {
191 value: string;
192};
193
194export type Phrase = {
195 value: string;
196 boost?: number;
197};
198
199export const markerFromItems = (
200 items: readonly { value?: string }[] | null | undefined,
201) => (items ?? []).find((item) => isOwnershipMarker(item.value))?.value;
202
203export const markerFromPhrases = (
204 phrases: readonly { value?: string }[] | null | undefined,
205) => (phrases ?? []).find((phrase) => isOwnershipMarker(phrase.value))?.value;
206
207export const stripOwnershipItems = (
208 items: readonly { value?: string }[] | null | undefined,
209): ClassItem[] =>
210 (items ?? [])
211 .filter((item) => !isOwnershipMarker(item.value))
212 .map((item) => ({ value: item.value ?? "" }))
213 .filter((item) => item.value.length > 0);
214
215export const stripOwnershipPhrases = (
216 phrases: readonly { value?: string; boost?: number }[] | null | undefined,
217): Phrase[] =>
218 (phrases ?? [])
219 .filter((phrase) => !isOwnershipMarker(phrase.value))
220 .map((phrase) => ({
221 value: phrase.value ?? "",
222 boost: phrase.boost,
223 }))
224 .filter((phrase) => phrase.value.length > 0);
225
226export const sameItems = (
227 left: readonly { value?: string }[] | undefined,
228 right: readonly { value?: string }[] | undefined,
229) =>
230 sameJson(
231 stripOwnershipItems(left)
232 .map((item) => item.value)
233 .sort(),
234 stripOwnershipItems(right)
235 .map((item) => item.value)
236 .sort(),
237 );
238
239export const samePhrases = (
240 left: readonly { value?: string; boost?: number }[] | undefined,
241 right: readonly { value?: string; boost?: number }[] | undefined,
242) =>
243 sameJson(
244 stripOwnershipPhrases(left)
245 .map((phrase) => ({
246 value: phrase.value,
247 boost: phrase.boost ?? 0,
248 }))
249 .sort((a, b) => a.value.localeCompare(b.value)),
250 stripOwnershipPhrases(right)
251 .map((phrase) => ({
252 value: phrase.value,
253 boost: phrase.boost ?? 0,
254 }))
255 .sort((a, b) => a.value.localeCompare(b.value)),
256 );
257
258const emptyList = <A>() => Effect.succeed([] as A[]);
259
260export class DeleteNotConfirmed extends Data.TaggedError(
261 "GCP.Speech.DeleteNotConfirmed",
262)<{}> {}
263
264/** Poll until the resource is gone; fails if it is still readable after ~60s. */
265export const waitUntilGone = <A, E, R>(
266 get: Effect.Effect<A | undefined, E, R>,
267) =>
268 get.pipe(
269 Effect.repeat({
270 schedule: Schedule.spaced("2 seconds"),
271 until: (value) => value === undefined,
272 times: 30,
273 }),
274 Effect.flatMap((value): Effect.Effect<void, DeleteNotConfirmed> =>
275 value === undefined ? Effect.void : Effect.fail(new DeleteNotConfirmed()),
276 ),
277 );
278
279const collectPages = <Page, Item, E, R>(
280 pages: Stream.Stream<Page, E, R>,
281 items: (page: Page) => readonly Item[] | null | undefined,
282) =>
283 pages.pipe(
284 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
285 Stream.runCollect,
286 Effect.map((chunk): Item[] => Array.from(chunk)),
287 );
288
289export const getCustomClass = (name: string) =>
290 name.length === 0
291 ? Effect.succeed(undefined)
292 : speech
293 .getProjectsLocationsCustomClasses({ name })
294 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
295
296export const getPhraseSet = (name: string) =>
297 name.length === 0
298 ? Effect.succeed(undefined)
299 : speech
300 .getProjectsLocationsPhraseSets({ name })
301 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
302
303export const listCustomClassesAt = (parent: string) =>
304 parent.length === 0
305 ? emptyList<speech.CustomClass>()
306 : collectPages(
307 speech.listProjectsLocationsCustomClasses.pages({
308 parent,
309 pageSize: 100,
310 }),
311 (page) => page.customClasses,
312 ).pipe(
313 Effect.catchTag("NotFound", () => emptyList<speech.CustomClass>()),
314 );
315
316export const listPhraseSetsAt = (parent: string) =>
317 parent.length === 0
318 ? emptyList<speech.PhraseSet>()
319 : collectPages(
320 speech.listProjectsLocationsPhraseSets.pages({
321 parent,
322 pageSize: 100,
323 }),
324 (page) => page.phraseSets,
325 ).pipe(Effect.catchTag("NotFound", () => emptyList<speech.PhraseSet>()));
326
327const listLocationParents = (project: string) =>
328 LIST_LOCATIONS.map((location) => locationParent(project, location));
329
330export const listProjectCustomClasses = (project: string) =>
331 Effect.gen(function* () {
332 const groups = yield* Effect.forEach(
333 listLocationParents(project),
334 listCustomClassesAt,
335 { concurrency: 2 },
336 );
337 const seen = new Set<string>();
338 const classes: speech.CustomClass[] = [];
339 for (const customClass of groups.flat()) {
340 const name = customClass.name ?? "";
341 if (name.length === 0 || seen.has(name)) continue;
342 seen.add(name);
343 classes.push(customClass);
344 }
345 return classes;
346 });
347
348export const listProjectPhraseSets = (project: string) =>
349 Effect.gen(function* () {
350 const groups = yield* Effect.forEach(
351 listLocationParents(project),
352 listPhraseSetsAt,
353 { concurrency: 2 },
354 );
355 const seen = new Set<string>();
356 const phraseSets: speech.PhraseSet[] = [];
357 for (const phraseSet of groups.flat()) {
358 const name = phraseSet.name ?? "";
359 if (name.length === 0 || seen.has(name)) continue;
360 seen.add(name);
361 phraseSets.push(phraseSet);
362 }
363 return phraseSets;
364 });
365
366/**
367 * Classes and phrase sets have no labels. Only ones stamped with a legacy
368 * `alc …` ownership item by earlier Alchemy versions are discoverable here;
369 * current ones are tracked through state alone.
370 */
371export const listOwnedCustomClasses = (project: string) =>
372 listProjectCustomClasses(project).pipe(
373 Effect.map((classes) =>
374 classes.filter((customClass) =>
375 hasOwnershipMarker(markerFromItems(customClass.items)),
376 ),
377 ),
378 );
379
380export const listOwnedPhraseSets = (project: string) =>
381 listProjectPhraseSets(project).pipe(
382 Effect.map((phraseSets) =>
383 phraseSets.filter((phraseSet) =>
384 hasOwnershipMarker(markerFromPhrases(phraseSet.phrases)),
385 ),
386 ),
387 );
388
389export const deleteCustomClass = (name: string) =>
390 speech
391 .deleteProjectsLocationsCustomClasses({ name })
392 .pipe(Effect.catchTag("NotFound", () => Effect.void));
393
394export const deletePhraseSet = (name: string) =>
395 speech
396 .deleteProjectsLocationsPhraseSets({ name })
397 .pipe(Effect.catchTag("NotFound", () => Effect.void));