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 DEFAULT_LOCATION,
12 deletePhraseSet,
13 getPhraseSet,
14 listOwnedPhraseSets,
15 locationParent,
16 markerFromPhrases,
17 normalizeLocation,
18 parseResourceName,
19 type Phrase,
20 replaceOnIdentity,
21 resourceNameOf,
22 sameNumber,
23 samePhrases,
24 stripOwnershipPhrases,
25 toPhysicalId,
26 updateMaskOf,
27 waitUntilGone,
28} from "./internal.ts";
29
30export type { Phrase };
31
32export type PhraseSetProps = {
33 /**
34 * Speech-to-Text Adaptation location. The global Speech endpoint
35 * accepts `global`; `us` and `eu` need matching regional endpoints.
36 * Immutable — changing it replaces the phrase set.
37 * @default "global"
38 */
39 location?: string;
40 /**
41 * Phrase set id (the `{phrase_set}` segment of
42 * `projects/{project}/locations/{location}/phraseSets/{phrase_set}`).
43 * If omitted, a unique id is generated. Letters, numbers, and hyphens;
44 * 4-63 characters; must start with a letter. Immutable — changing it
45 * replaces the phrase set.
46 */
47 phraseSetId?: string;
48 /**
49 * Phrase hints. Phrase values can reference a custom class as
50 * `${custom_class_id}`.
51 */
52 phrases?: Phrase[];
53 /**
54 * Hint boost applied to every phrase that does not set its own boost.
55 * Typical values are between 0 (exclusive) and 20.
56 */
57 boost?: number;
58};
59
60export type PhraseSet = Resource<
61 "GCP.Speech.PhraseSet",
62 PhraseSetProps,
63 {
64 /** Full resource name `projects/{project}/locations/{location}/phraseSets/{phrase_set}`. */
65 name: string;
66 /** Phrase set id (last path segment). */
67 phraseSetId: string;
68 /** Project id. */
69 project: string;
70 /** Adaptation location. */
71 location: string;
72 /** Phrase hints. */
73 phrases: Phrase[];
74 /** Phrase-set-level hint boost. */
75 boost: number | undefined;
76 /** Server-assigned uid, if present. */
77 uid: string | undefined;
78 /** Lifecycle state, if present. */
79 state: string | undefined;
80 /** KMS key encrypting phrase set content, if any. */
81 kmsKeyName: string | undefined;
82 },
83 never,
84 Providers
85>;
86
87/**
88 * A Speech-to-Text Adaptation phrase set. Phrase sets bias recognition
89 * toward listed words and phrases, including references to custom
90 * classes (`${custom_class_id}`).
91 *
92 * Speech-to-Text v1 phrase sets have no labels field and Alchemy does
93 * not write ownership into the phrases (they bias recognition). A
94 * generated id identifies the set; one with an explicit id that Alchemy
95 * has no state for is reported as unowned. Location and
96 * phrase set id are identity — changing either replaces the set.
97 * `phrases` and `boost` update in place.
98 *
99 * ### Creating a Phrase Set
100 * **Example:** Generated id
101 * ```typescript
102 * const hints = yield* GCP.Speech.PhraseSet("Hints", {
103 * phrases: [{ value: "weather", boost: 10 }],
104 * boost: 5,
105 * });
106 * ```
107 *
108 * **Example:** Explicit id with a custom class reference
109 * ```typescript
110 * const hints = yield* GCP.Speech.PhraseSet("Hints", {
111 * phraseSetId: "weather-hints",
112 * phrases: [{ value: "sail on ${passenger-ships}" }],
113 * });
114 * ```
115 *
116 * ### Updating a Phrase Set
117 * **Example:** Add a phrase and change boost
118 * ```typescript
119 * const hints = yield* GCP.Speech.PhraseSet("Hints", {
120 * phrases: [
121 * { value: "weather", boost: 15 },
122 * { value: "forecast" },
123 * ],
124 * boost: 8,
125 * });
126 * ```
127 *
128 * @resource
129 * @category Speech
130 */
131export const PhraseSet = Resource<PhraseSet>("GCP.Speech.PhraseSet");
132
133export class PhraseSetNotResolved extends Data.TaggedError(
134 "GCP.Speech.PhraseSetNotResolved",
135)<{
136 name: string;
137}> {}
138
139const phraseSetNameOf = (
140 project: string,
141 location: string,
142 phraseSetId: string,
143) => resourceNameOf(project, location, "phraseSets", phraseSetId);
144
145const toAttrs = (phraseSet: speech.PhraseSet, project: string) => {
146 const name = phraseSet.name ?? "";
147 const parsed = parseResourceName(name, "phraseSets");
148 return {
149 name,
150 phraseSetId: parsed.id,
151 // Speech echoes names with the project number; report the project id.
152 project,
153 location: parsed.location,
154 phrases: stripOwnershipPhrases(phraseSet.phrases),
155 boost: phraseSet.boost,
156 uid: phraseSet.uid,
157 state: phraseSet.state,
158 kmsKeyName: phraseSet.kmsKeyName,
159 };
160};
161
162export const PhraseSetProvider = () =>
163 Provider.succeed(PhraseSet, {
164 stables: ["name", "phraseSetId", "project", "location", "uid"],
165
166 diff: Effect.fn(function* ({ news, olds, output }) {
167 if (!isResolved(news)) return undefined;
168 return replaceOnIdentity({
169 previousId: olds?.phraseSetId ?? output?.phraseSetId,
170 nextId: news.phraseSetId,
171 previousLocation: olds?.location ?? output?.location,
172 nextLocation: news.location ?? olds?.location ?? output?.location,
173 });
174 }),
175
176 read: Effect.fn(function* ({ id, olds, output }) {
177 const env = yield* GcpEnvironment.current;
178 const location = normalizeLocation(olds?.location ?? output?.location);
179 const phraseSetId = yield* toPhysicalId(
180 id,
181 olds?.phraseSetId,
182 output?.phraseSetId,
183 );
184 const existing = yield* getPhraseSet(
185 output?.name ?? phraseSetNameOf(env.project, location, phraseSetId),
186 );
187 if (existing === undefined) return undefined;
188 const attrs = toAttrs(existing, env.project);
189 // No labels: a generated id derives from this stack, stage, logical
190 // id and instance; an explicit id is only ours when state has it.
191 return output !== undefined || olds?.phraseSetId === undefined
192 ? attrs
193 : Unowned(attrs);
194 }),
195
196 list: () =>
197 Effect.gen(function* () {
198 const env = yield* GcpEnvironment.current;
199 const phraseSets = yield* listOwnedPhraseSets(env.project);
200 return phraseSets.map((phraseSet) => toAttrs(phraseSet, env.project));
201 }),
202
203 reconcile: Effect.fn(function* ({ id, news, output }) {
204 const env = yield* GcpEnvironment.current;
205 const location = normalizeLocation(
206 news.location ?? output?.location ?? DEFAULT_LOCATION,
207 );
208 const phraseSetId = yield* toPhysicalId(
209 id,
210 news.phraseSetId,
211 output?.phraseSetId,
212 );
213 const phrases = news.phrases ?? [];
214 const name =
215 output?.name ?? phraseSetNameOf(env.project, location, phraseSetId);
216
217 let current = yield* getPhraseSet(name);
218
219 if (current === undefined) {
220 const created = yield* speech
221 .createProjectsLocationsPhraseSets({
222 parent: locationParent(env.project, location),
223 body: {
224 phraseSetId,
225 phraseSet: {
226 phrases,
227 boost: news.boost,
228 },
229 },
230 })
231 .pipe(
232 Effect.catchTag("Conflict", () =>
233 getPhraseSet(phraseSetNameOf(env.project, location, phraseSetId)),
234 ),
235 );
236 current = created ?? undefined;
237 if (current?.name) {
238 current = (yield* getPhraseSet(current.name)) ?? current;
239 }
240 }
241
242 if (current === undefined) {
243 return yield* new PhraseSetNotResolved({
244 name: name || phraseSetNameOf(env.project, location, phraseSetId),
245 });
246 }
247
248 const currentName = current.name ?? name;
249 const updateMask = updateMaskOf(
250 markerFromPhrases(current.phrases) === undefined &&
251 samePhrases(current.phrases, news.phrases)
252 ? undefined
253 : "phrases",
254 sameNumber(current.boost, news.boost) ? undefined : "boost",
255 );
256 if (updateMask.length > 0) {
257 current = yield* speech.patchProjectsLocationsPhraseSets({
258 name: currentName,
259 updateMask,
260 body: {
261 name: currentName,
262 phrases,
263 boost: news.boost,
264 },
265 });
266 }
267
268 return toAttrs(current, env.project);
269 }),
270
271 delete: Effect.fn(function* ({ output }) {
272 if (!output.name) return;
273 yield* deletePhraseSet(output.name);
274 yield* waitUntilGone(getPhraseSet(output.name));
275 }),
276 });