| 1 | import * as speech from "@distilled.cloud/gcp/speech_v1"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 7 | import { alchemyLabelKeys } from "../Labels.ts"; |
| 8 | |
| 9 | /** Speech-to-Text v1 Adaptation is served from the global endpoint. */ |
| 10 | export const DEFAULT_LOCATION = "global"; |
| 11 | /** The global Speech endpoint only serves the `global` location. */ |
| 12 | export const LIST_LOCATIONS = ["global"] as const; |
| 13 | export const MAX_ID_LENGTH = 63; |
| 14 | export const MAX_ITEM_LENGTH = 100; |
| 15 | |
| 16 | export class ResourceNotResolved extends Data.TaggedError( |
| 17 | "GCP.Speech.ResourceNotResolved", |
| 18 | )<{ |
| 19 | name: string; |
| 20 | }> {} |
| 21 | |
| 22 | export 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 | |
| 40 | export const isOwnershipMarker = (text: string | undefined) => |
| 41 | (text ?? "").trim().startsWith("alc "); |
| 42 | |
| 43 | export const hasOwnershipMarker = (text: string | undefined) => { |
| 44 | const { labels } = parseOwnershipMarker(text); |
| 45 | return Object.keys(labels).some((key) => key.startsWith("alchemy-")); |
| 46 | }; |
| 47 | |
| 48 | export const lastSegment = (value: string) => { |
| 49 | const trimmed = value.replace(/\/+$/, ""); |
| 50 | const parts = trimmed.split("/"); |
| 51 | return parts[parts.length - 1] || trimmed; |
| 52 | }; |
| 53 | |
| 54 | export const normalizeLocation = (location: string | undefined) => |
| 55 | lastSegment(location ?? DEFAULT_LOCATION).toLowerCase(); |
| 56 | |
| 57 | export const locationParent = (project: string, location: string | undefined) => |
| 58 | `projects/${project}/locations/${normalizeLocation(location)}`; |
| 59 | |
| 60 | export 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 | |
| 79 | export 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 | |
| 90 | export const sameText = (left: string | undefined, right: string | undefined) => |
| 91 | (left ?? "") === (right ?? ""); |
| 92 | |
| 93 | export const sameNumber = ( |
| 94 | left: number | undefined, |
| 95 | right: number | undefined, |
| 96 | ) => (left ?? 0) === (right ?? 0); |
| 97 | |
| 98 | export 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 | |
| 119 | export const fingerprint = (value: unknown): string => |
| 120 | JSON.stringify(canonical(value) ?? null); |
| 121 | |
| 122 | export const sameJson = (left: unknown, right: unknown) => |
| 123 | fingerprint(left) === fingerprint(right); |
| 124 | |
| 125 | export const updateMaskOf = (...fields: Array<string | undefined>) => |
| 126 | fields.filter((field): field is string => field !== undefined).join(","); |
| 127 | |
| 128 | export 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 | |
| 160 | export 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 | |
| 190 | export type ClassItem = { |
| 191 | value: string; |
| 192 | }; |
| 193 | |
| 194 | export type Phrase = { |
| 195 | value: string; |
| 196 | boost?: number; |
| 197 | }; |
| 198 | |
| 199 | export const markerFromItems = ( |
| 200 | items: readonly { value?: string }[] | null | undefined, |
| 201 | ) => (items ?? []).find((item) => isOwnershipMarker(item.value))?.value; |
| 202 | |
| 203 | export const markerFromPhrases = ( |
| 204 | phrases: readonly { value?: string }[] | null | undefined, |
| 205 | ) => (phrases ?? []).find((phrase) => isOwnershipMarker(phrase.value))?.value; |
| 206 | |
| 207 | export 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 | |
| 215 | export 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 | |
| 226 | export 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 | |
| 239 | export 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 | |
| 258 | const emptyList = <A>() => Effect.succeed([] as A[]); |
| 259 | |
| 260 | export 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. */ |
| 265 | export 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 | |
| 279 | const 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 | |
| 289 | export 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 | |
| 296 | export 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 | |
| 303 | export 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 | |
| 316 | export 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 | |
| 327 | const listLocationParents = (project: string) => |
| 328 | LIST_LOCATIONS.map((location) => locationParent(project, location)); |
| 329 | |
| 330 | export 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 | |
| 348 | export 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 | */ |
| 371 | export 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 | |
| 380 | export 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 | |
| 389 | export const deleteCustomClass = (name: string) => |
| 390 | speech |
| 391 | .deleteProjectsLocationsCustomClasses({ name }) |
| 392 | .pipe(Effect.catchTag("NotFound", () => Effect.void)); |
| 393 | |
| 394 | export const deletePhraseSet = (name: string) => |
| 395 | speech |
| 396 | .deleteProjectsLocationsPhraseSets({ name }) |
| 397 | .pipe(Effect.catchTag("NotFound", () => Effect.void)); |