1import * as datalabeling from "@distilled.cloud/gcp/datalabeling_v1beta1";
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 { createInternalLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 encodeOwnership,
13 findOwned,
14 hasOwnershipMarker,
15 ignoreGone,
16 listAnnotationSpecSets,
17 noRetryLayer,
18 ownedByAlchemy,
19 parseOwnership,
20 parseResourceName,
21 projectParent,
22 replaceOnIdentity,
23 retryDelete,
24 retryTransient,
25 sameJson,
26 sameText,
27 toDisplayName,
28 waitUntilGone,
29} from "./internal.ts";
30
31export type AnnotationSpec = {
32 /**
33 * Display name of this spec (the class label). Maximum 64 characters.
34 */
35 displayName: string;
36 /**
37 * Optional description of this spec. Maximum 10,000 characters.
38 */
39 description?: string;
40};
41
42export type AnnotationSpecSetProps = {
43 /**
44 * Annotation spec set id (the last segment of
45 * `projects/{project}/annotationSpecSets/{annotation_spec_set}`).
46 * Server-assigned on create. Immutable — changing it replaces the set.
47 */
48 annotationSpecSetId?: string;
49 /**
50 * Display name. Maximum 64 characters. Required by the API; Alchemy
51 * falls back to a generated name. Immutable — changing it replaces
52 * the set.
53 */
54 displayName?: string;
55 /**
56 * Human-readable description. Annotation spec sets have no labels
57 * field, so Alchemy stamps ownership into this field. Immutable —
58 * changing it replaces the set.
59 */
60 description?: string;
61 /**
62 * The possible labels for a labeling task. Required. Immutable —
63 * changing them replaces the set.
64 */
65 annotationSpecs: AnnotationSpec[];
66};
67
68export type AnnotationSpecSet = Resource<
69 "GCP.DataLabeling.AnnotationSpecSet",
70 AnnotationSpecSetProps,
71 {
72 /** Full resource name `projects/{project}/annotationSpecSets/{id}`. */
73 name: string;
74 /** Annotation spec set id (last path segment). */
75 annotationSpecSetId: string;
76 /** Project id. */
77 project: string;
78 /** Display name. */
79 displayName: string | undefined;
80 /** User description with the Alchemy ownership prefix stripped. */
81 description: string | undefined;
82 /** Annotation specs (class labels). */
83 annotationSpecs: AnnotationSpec[];
84 /** Related resources blocking changes. */
85 blockingResources: string[] | undefined;
86 },
87 never,
88 Providers
89>;
90
91/**
92 * A Data Labeling annotation spec set — a collection of class labels
93 * used by labeling tasks.
94 *
95 * Spec sets are immutable after create. There is no labels API, so
96 * Alchemy stamps ownership into `description` so `list` / nuke can find
97 * them. Ids are server-assigned.
98 *
99 * ### Creating an Annotation Spec Set
100 * **Example:** Image classes
101 * ```typescript
102 * const specs = yield* GCP.DataLabeling.AnnotationSpecSet("Classes", {
103 * displayName: "pets",
104 * annotationSpecs: [
105 * { displayName: "dog" },
106 * { displayName: "cat" },
107 * ],
108 * });
109 * ```
110 *
111 * **Example:** Specs with descriptions
112 * ```typescript
113 * const specs = yield* GCP.DataLabeling.AnnotationSpecSet("Classes", {
114 * displayName: "sentiment",
115 * description: "review polarity",
116 * annotationSpecs: [
117 * { displayName: "positive", description: "favorable" },
118 * { displayName: "negative", description: "unfavorable" },
119 * ],
120 * });
121 * ```
122 *
123 * @resource
124 * @category DataLabeling
125 */
126export const AnnotationSpecSet = Resource<AnnotationSpecSet>(
127 "GCP.DataLabeling.AnnotationSpecSet",
128);
129
130export class AnnotationSpecSetNotResolved extends Data.TaggedError(
131 "GCP.DataLabeling.AnnotationSpecSetNotResolved",
132)<{
133 name: string;
134}> {}
135
136const resourceName = (project: string, annotationSpecSetId: string) =>
137 `${projectParent(project)}/annotationSpecSets/${annotationSpecSetId}`;
138
139const specsOf = (
140 specs:
141 | readonly datalabeling.GoogleCloudDatalabelingV1beta1AnnotationSpec[]
142 | readonly AnnotationSpec[]
143 | undefined,
144): AnnotationSpec[] =>
145 (specs ?? []).map((spec) => ({
146 displayName: spec.displayName ?? "",
147 description: spec.description,
148 }));
149
150const toAttrs = (
151 set: datalabeling.GoogleCloudDatalabelingV1beta1AnnotationSpecSet,
152 project: string,
153) => {
154 const name = set.name ?? "";
155 const parsed = parseResourceName(name, "annotationSpecSets");
156 return {
157 name,
158 annotationSpecSetId: parsed.id,
159 project: parsed.project || project,
160 displayName: set.displayName,
161 description: parseOwnership(set.description).text,
162 annotationSpecs: specsOf(set.annotationSpecs),
163 blockingResources: set.blockingResources,
164 };
165};
166
167const getByName = (name: string) =>
168 name.length === 0
169 ? Effect.succeed(undefined)
170 : datalabeling.getProjectsAnnotationSpecSets({ name }).pipe(
171 Effect.provide(noRetryLayer),
172 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
173 Effect.catchTag("BadGateway", () => Effect.succeed(undefined)),
174 );
175
176const findByOwnership = (id: string, project: string) =>
177 Effect.gen(function* () {
178 const rows = yield* listAnnotationSpecSets(projectParent(project));
179 return yield* findOwned(id, rows, (row) => row.description);
180 });
181
182export const AnnotationSpecSetProvider = () =>
183 Provider.succeed(AnnotationSpecSet, {
184 stables: ["name", "annotationSpecSetId", "project"],
185
186 diff: Effect.fn(function* ({ news, olds, output }) {
187 if (!isResolved(news)) return undefined;
188 const extra =
189 (news.displayName !== undefined &&
190 output?.displayName !== undefined &&
191 !sameText(news.displayName, output.displayName)) ||
192 (olds !== undefined &&
193 !sameText(news.description, output?.description)) ||
194 (output !== undefined &&
195 !sameJson(specsOf(news.annotationSpecs), output.annotationSpecs));
196 return replaceOnIdentity({
197 previousId: olds?.annotationSpecSetId ?? output?.annotationSpecSetId,
198 nextId: news.annotationSpecSetId,
199 extra,
200 });
201 }),
202
203 read: Effect.fn(function* ({ id, olds, output }) {
204 const env = yield* GcpEnvironment.current;
205 const annotationSpecSetId =
206 olds?.annotationSpecSetId ??
207 output?.annotationSpecSetId ??
208 (output?.name
209 ? parseResourceName(output.name, "annotationSpecSets").id
210 : "");
211 const name =
212 output?.name ??
213 (annotationSpecSetId.length > 0
214 ? resourceName(env.project, annotationSpecSetId)
215 : "");
216 const existing =
217 (yield* getByName(name)) ?? (yield* findByOwnership(id, env.project));
218 if (existing === undefined) return undefined;
219 const attrs = toAttrs(existing, env.project);
220 return (yield* ownedByAlchemy(id, existing.description))
221 ? attrs
222 : Unowned(attrs);
223 }),
224
225 list: () =>
226 Effect.gen(function* () {
227 const env = yield* GcpEnvironment.current;
228 const rows = yield* listAnnotationSpecSets(projectParent(env.project));
229 return rows
230 .filter((row) => hasOwnershipMarker(row.description))
231 .map((row) => toAttrs(row, env.project));
232 }),
233
234 reconcile: Effect.fn(function* ({ id, news, output }) {
235 const env = yield* GcpEnvironment.current;
236 const annotationSpecSetId =
237 news.annotationSpecSetId ?? output?.annotationSpecSetId;
238 const name =
239 output?.name ??
240 (annotationSpecSetId !== undefined
241 ? resourceName(env.project, annotationSpecSetId)
242 : "");
243 const ownership = yield* createInternalLabels(id);
244 const displayName = yield* toDisplayName(
245 id,
246 news.displayName,
247 output?.displayName,
248 );
249 const description = encodeOwnership(ownership, news.description);
250 const annotationSpecs = specsOf(news.annotationSpecs);
251
252 let current =
253 (yield* getByName(name)) ?? (yield* findByOwnership(id, env.project));
254
255 if (current === undefined) {
256 const created = yield* retryTransient(
257 datalabeling.createProjectsAnnotationSpecSets({
258 parent: projectParent(env.project),
259 body: {
260 annotationSpecSet: {
261 displayName,
262 description,
263 annotationSpecs,
264 },
265 },
266 }),
267 ).pipe(
268 Effect.catchTag("Conflict", () => findByOwnership(id, env.project)),
269 );
270 current = created ?? undefined;
271 }
272
273 if (current === undefined) {
274 return yield* new AnnotationSpecSetNotResolved({
275 name: name || projectParent(env.project),
276 });
277 }
278
279 return toAttrs(current, env.project);
280 }),
281
282 delete: Effect.fn(function* ({ output }) {
283 if (!output.name) return;
284 yield* ignoreGone(
285 retryDelete(
286 datalabeling.deleteProjectsAnnotationSpecSets({ name: output.name }),
287 ),
288 );
289 yield* waitUntilGone(getByName(output.name));
290 }),
291 });