1import * as speech from "@distilled.cloud/gcp/speech_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 type ClassItem,
12 DEFAULT_LOCATION,
13 deleteCustomClass,
14 getCustomClass,
15 listOwnedCustomClasses,
16 locationParent,
17 markerFromItems,
18 normalizeLocation,
19 parseResourceName,
20 replaceOnIdentity,
21 resourceNameOf,
22 sameItems,
23 stripOwnershipItems,
24 toPhysicalId,
25 updateMaskOf,
26 waitUntilGone,
27} from "./internal.ts";
28
29export type { ClassItem };
30
31export type CustomClassProps = {
32 /**
33 * Speech-to-Text Adaptation location. The global Speech endpoint
34 * accepts `global`; `us` and `eu` need matching regional endpoints.
35 * Immutable — changing it replaces the custom class.
36 * @default "global"
37 */
38 location?: string;
39 /**
40 * Custom class id (the `{custom_class}` segment of
41 * `projects/{project}/locations/{location}/customClasses/{custom_class}`).
42 * If omitted, a unique id is generated. Letters, numbers, and hyphens;
43 * 4-63 characters; must start with a letter. Immutable — changing it
44 * replaces the custom class.
45 */
46 customClassId?: string;
47 /**
48 * Class items (words or phrases that represent one concept).
49 */
50 items?: ClassItem[];
51};
52
53export type CustomClass = Resource<
54 "GCP.Speech.CustomClass",
55 CustomClassProps,
56 {
57 /** Full resource name `projects/{project}/locations/{location}/customClasses/{custom_class}`. */
58 name: string;
59 /** Custom class id (last path segment). */
60 customClassId: string;
61 /** Project id. */
62 project: string;
63 /** Adaptation location. */
64 location: string;
65 /** Class items. */
66 items: ClassItem[];
67 /** Server-assigned uid, if present. */
68 uid: string | undefined;
69 /** Lifecycle state, if present. */
70 state: string | undefined;
71 /** KMS key encrypting class items, if any. */
72 kmsKeyName: string | undefined;
73 },
74 never,
75 Providers
76>;
77
78/**
79 * A Speech-to-Text Adaptation custom class. Custom classes group words
80 * that represent one concept (for example passenger ship names) and are
81 * referenced from PhraseSet hints as `${custom_class_id}`.
82 *
83 * Speech-to-Text v1 classes have no labels field and Alchemy does not
84 * write ownership into the items (they steer recognition). A generated id
85 * identifies the class; one with an explicit id that Alchemy has no state
86 * for is reported as unowned. Location and custom class id are identity — changing either replaces the class.
87 * `items` update in place.
88 *
89 * ### Creating a Custom Class
90 * **Example:** Generated id
91 * ```typescript
92 * const ships = yield* GCP.Speech.CustomClass("Ships", {
93 * items: [{ value: "sloop" }, { value: "ketch" }],
94 * });
95 * ```
96 *
97 * **Example:** Explicit id and location
98 * ```typescript
99 * const ships = yield* GCP.Speech.CustomClass("Ships", {
100 * location: "global",
101 * customClassId: "passenger-ships",
102 * items: [{ value: "sloop" }],
103 * });
104 * ```
105 *
106 * ### Updating a Custom Class
107 * **Example:** Replace the class items
108 * ```typescript
109 * const ships = yield* GCP.Speech.CustomClass("Ships", {
110 * items: [{ value: "brig" }, { value: "barque" }],
111 * });
112 * ```
113 *
114 * @resource
115 * @category Speech
116 */
117export const CustomClass = Resource<CustomClass>("GCP.Speech.CustomClass");
118
119export class CustomClassNotResolved extends Data.TaggedError(
120 "GCP.Speech.CustomClassNotResolved",
121)<{
122 name: string;
123}> {}
124
125const customClassNameOf = (
126 project: string,
127 location: string,
128 customClassId: string,
129) => resourceNameOf(project, location, "customClasses", customClassId);
130
131const toAttrs = (customClass: speech.CustomClass, project: string) => {
132 const name = customClass.name ?? "";
133 const parsed = parseResourceName(name, "customClasses");
134 return {
135 name,
136 customClassId: parsed.id || customClass.customClassId || "",
137 // Speech echoes names with the project number; report the project id.
138 project,
139 location: parsed.location,
140 items: stripOwnershipItems(customClass.items),
141 uid: customClass.uid,
142 state: customClass.state,
143 kmsKeyName: customClass.kmsKeyName,
144 };
145};
146
147export const CustomClassProvider = () =>
148 Provider.succeed(CustomClass, {
149 stables: ["name", "customClassId", "project", "location", "uid"],
150
151 diff: Effect.fn(function* ({ news, olds, output }) {
152 if (!isResolved(news)) return undefined;
153 return replaceOnIdentity({
154 previousId: olds?.customClassId ?? output?.customClassId,
155 nextId: news.customClassId,
156 previousLocation: olds?.location ?? output?.location,
157 nextLocation: news.location ?? olds?.location ?? output?.location,
158 });
159 }),
160
161 read: Effect.fn(function* ({ id, olds, output }) {
162 const env = yield* GcpEnvironment.current;
163 const location = normalizeLocation(olds?.location ?? output?.location);
164 const customClassId = yield* toPhysicalId(
165 id,
166 olds?.customClassId,
167 output?.customClassId,
168 );
169 const existing = yield* getCustomClass(
170 output?.name ?? customClassNameOf(env.project, location, customClassId),
171 );
172 if (existing === undefined) return undefined;
173 const attrs = toAttrs(existing, env.project);
174 // No labels: a generated id derives from this stack, stage, logical
175 // id and instance; an explicit id is only ours when state has it.
176 return output !== undefined || olds?.customClassId === undefined
177 ? attrs
178 : Unowned(attrs);
179 }),
180
181 list: () =>
182 Effect.gen(function* () {
183 const env = yield* GcpEnvironment.current;
184 const classes = yield* listOwnedCustomClasses(env.project);
185 return classes.map((customClass) => toAttrs(customClass, env.project));
186 }),
187
188 reconcile: Effect.fn(function* ({ id, news, output }) {
189 const env = yield* GcpEnvironment.current;
190 const location = normalizeLocation(
191 news.location ?? output?.location ?? DEFAULT_LOCATION,
192 );
193 const customClassId = yield* toPhysicalId(
194 id,
195 news.customClassId,
196 output?.customClassId,
197 );
198 const items = news.items ?? [];
199 const name =
200 output?.name ?? customClassNameOf(env.project, location, customClassId);
201
202 let current = yield* getCustomClass(name);
203
204 if (current === undefined) {
205 const created = yield* speech
206 .createProjectsLocationsCustomClasses({
207 parent: locationParent(env.project, location),
208 body: {
209 customClassId,
210 customClass: { items },
211 },
212 })
213 .pipe(
214 Effect.catchTag("Conflict", () =>
215 getCustomClass(
216 customClassNameOf(env.project, location, customClassId),
217 ),
218 ),
219 );
220 current = created ?? undefined;
221 if (current?.name) {
222 current = (yield* getCustomClass(current.name)) ?? current;
223 }
224 }
225
226 if (current === undefined) {
227 return yield* new CustomClassNotResolved({
228 name: name || customClassNameOf(env.project, location, customClassId),
229 });
230 }
231
232 const currentName = current.name ?? name;
233 const updateMask = updateMaskOf(
234 markerFromItems(current.items) === undefined &&
235 sameItems(current.items, news.items)
236 ? undefined
237 : "items",
238 );
239 if (updateMask.length > 0) {
240 current = yield* speech.patchProjectsLocationsCustomClasses({
241 name: currentName,
242 updateMask,
243 body: {
244 name: currentName,
245 items,
246 },
247 });
248 }
249
250 return toAttrs(current, env.project);
251 }),
252
253 delete: Effect.fn(function* ({ output }) {
254 if (!output.name) return;
255 yield* deleteCustomClass(output.name);
256 yield* waitUntilGone(getCustomClass(output.name));
257 }),
258 });