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 listInstructions,
17 noRetryLayer,
18 ownedByAlchemy,
19 parseOwnership,
20 parseResourceName,
21 projectParent,
22 replaceOnIdentity,
23 retryDelete,
24 retryTransient,
25 sameJson,
26 sameText,
27 toDisplayName,
28 waitForVisible,
29 waitUntilGone,
30} from "./internal.ts";
31import { resourceNameFromOperation, waitForOperation } from "./operations.ts";
32
33export type InstructionDataType =
34 datalabeling.GoogleCloudDatalabelingV1beta1InstructionDataTypeEnum;
35
36export type PdfInstruction = {
37 /**
38 * Cloud Storage URI of the PDF (`gs://...`).
39 */
40 gcsFileUri?: string;
41};
42
43export type CsvInstruction = {
44 /**
45 * Cloud Storage URI of the CSV (`gs://...`). Deprecated — the API
46 * only accepts PDF instructions.
47 */
48 gcsFileUri?: string;
49};
50
51export type InstructionProps = {
52 /**
53 * Instruction id (the last segment of
54 * `projects/{project}/instructions/{instruction}`). Server-assigned
55 * on create. Immutable — changing it replaces the instruction.
56 */
57 instructionId?: string;
58 /**
59 * Display name. Maximum 64 characters. Required by the API; Alchemy
60 * falls back to a generated name. Immutable — changing it replaces
61 * the instruction.
62 */
63 displayName?: string;
64 /**
65 * Data type this instruction applies to (`IMAGE`, `VIDEO`, `TEXT`,
66 * `GENERAL_DATA`). Immutable — changing it replaces the instruction.
67 */
68 dataType: InstructionDataType | (string & {});
69 /**
70 * Human-readable description. Instructions have no labels field, so
71 * Alchemy stamps ownership into this field. Immutable — changing it
72 * replaces the instruction.
73 */
74 description?: string;
75 /**
76 * PDF instruction stored in Cloud Storage. Currently the only
77 * supported instruction format. Immutable — changing it replaces the
78 * instruction.
79 */
80 pdfInstruction?: PdfInstruction;
81 /**
82 * Deprecated CSV instruction. Immutable — changing it replaces the
83 * instruction.
84 */
85 csvInstruction?: CsvInstruction;
86};
87
88export type Instruction = Resource<
89 "GCP.DataLabeling.Instruction",
90 InstructionProps,
91 {
92 /** Full resource name `projects/{project}/instructions/{instruction}`. */
93 name: string;
94 /** Instruction id (last path segment). */
95 instructionId: string;
96 /** Project id. */
97 project: string;
98 /** Display name. */
99 displayName: string | undefined;
100 /** Data type this instruction applies to. */
101 dataType: string | undefined;
102 /** User description with the Alchemy ownership prefix stripped. */
103 description: string | undefined;
104 /** PDF instruction, if set. */
105 pdfInstruction: PdfInstruction | undefined;
106 /** Deprecated CSV instruction, if set. */
107 csvInstruction: CsvInstruction | undefined;
108 /** RFC3339 creation timestamp. */
109 createTime: string | undefined;
110 /** RFC3339 last-update timestamp. */
111 updateTime: string | undefined;
112 /** Related resources blocking changes. */
113 blockingResources: string[] | undefined;
114 },
115 never,
116 Providers
117>;
118
119/**
120 * A Data Labeling instruction describing how human operators should
121 * label data. Create is a long-running operation. The current API only
122 * accepts a PDF stored in Cloud Storage.
123 *
124 * Instruction ids are server-assigned. There is no labels API, so
125 * Alchemy stamps ownership into `description` so `list` / nuke can find
126 * them. All input fields are immutable — changing them replaces the
127 * instruction.
128 *
129 * ### Creating an Instruction
130 * **Example:** Image labeling PDF
131 * ```typescript
132 * const instruction = yield* GCP.DataLabeling.Instruction("HowTo", {
133 * displayName: "image-classes",
134 * dataType: "IMAGE",
135 * pdfInstruction: {
136 * gcsFileUri: "gs://my-bucket/instructions.pdf",
137 * },
138 * });
139 * ```
140 *
141 * **Example:** Text labeling PDF with a description
142 * ```typescript
143 * const instruction = yield* GCP.DataLabeling.Instruction("HowTo", {
144 * displayName: "entity-extraction",
145 * dataType: "TEXT",
146 * description: "highlight product names",
147 * pdfInstruction: {
148 * gcsFileUri: "gs://my-bucket/text-instructions.pdf",
149 * },
150 * });
151 * ```
152 *
153 * @resource
154 * @category DataLabeling
155 */
156export const Instruction = Resource<Instruction>(
157 "GCP.DataLabeling.Instruction",
158);
159
160export class InstructionNotResolved extends Data.TaggedError(
161 "GCP.DataLabeling.InstructionNotResolved",
162)<{
163 name: string;
164}> {}
165
166const resourceName = (project: string, instructionId: string) =>
167 `${projectParent(project)}/instructions/${instructionId}`;
168
169const toAttrs = (
170 instruction: datalabeling.GoogleCloudDatalabelingV1beta1Instruction,
171 project: string,
172) => {
173 const name = instruction.name ?? "";
174 const parsed = parseResourceName(name, "instructions");
175 return {
176 name,
177 instructionId: parsed.id,
178 project: parsed.project || project,
179 displayName: instruction.displayName,
180 dataType: instruction.dataType,
181 description: parseOwnership(instruction.description).text,
182 pdfInstruction: instruction.pdfInstruction,
183 csvInstruction: instruction.csvInstruction,
184 createTime: instruction.createTime,
185 updateTime: instruction.updateTime,
186 blockingResources: instruction.blockingResources,
187 };
188};
189
190const getByName = (name: string) =>
191 name.length === 0
192 ? Effect.succeed(undefined)
193 : datalabeling.getProjectsInstructions({ name }).pipe(
194 Effect.provide(noRetryLayer),
195 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
196 Effect.catchTag("BadGateway", () => Effect.succeed(undefined)),
197 );
198
199const findByOwnership = (id: string, project: string) =>
200 Effect.gen(function* () {
201 const rows = yield* listInstructions(projectParent(project));
202 return yield* findOwned(id, rows, (row) => row.description);
203 });
204
205export const InstructionProvider = () =>
206 Provider.succeed(Instruction, {
207 stables: ["name", "instructionId", "project", "createTime"],
208
209 diff: Effect.fn(function* ({ news, olds, output }) {
210 if (!isResolved(news)) return undefined;
211 const extra =
212 (news.displayName !== undefined &&
213 output?.displayName !== undefined &&
214 !sameText(news.displayName, output.displayName)) ||
215 (output?.dataType !== undefined &&
216 !sameText(news.dataType, output.dataType)) ||
217 (olds !== undefined &&
218 !sameText(news.description, output?.description)) ||
219 (output !== undefined &&
220 !sameJson(news.pdfInstruction, output.pdfInstruction)) ||
221 (output !== undefined &&
222 !sameJson(news.csvInstruction, output.csvInstruction));
223 return replaceOnIdentity({
224 previousId: olds?.instructionId ?? output?.instructionId,
225 nextId: news.instructionId,
226 extra,
227 });
228 }),
229
230 read: Effect.fn(function* ({ id, olds, output }) {
231 const env = yield* GcpEnvironment.current;
232 const instructionId =
233 olds?.instructionId ??
234 output?.instructionId ??
235 (output?.name ? parseResourceName(output.name, "instructions").id : "");
236 const name =
237 output?.name ??
238 (instructionId.length > 0
239 ? resourceName(env.project, instructionId)
240 : "");
241 const existing =
242 (yield* getByName(name)) ?? (yield* findByOwnership(id, env.project));
243 if (existing === undefined) return undefined;
244 const attrs = toAttrs(existing, env.project);
245 return (yield* ownedByAlchemy(id, existing.description))
246 ? attrs
247 : Unowned(attrs);
248 }),
249
250 list: () =>
251 Effect.gen(function* () {
252 const env = yield* GcpEnvironment.current;
253 const rows = yield* listInstructions(projectParent(env.project));
254 return rows
255 .filter((row) => hasOwnershipMarker(row.description))
256 .map((row) => toAttrs(row, env.project));
257 }),
258
259 reconcile: Effect.fn(function* ({ id, news, output }) {
260 const env = yield* GcpEnvironment.current;
261 const instructionId = news.instructionId ?? output?.instructionId;
262 const name =
263 output?.name ??
264 (instructionId !== undefined
265 ? resourceName(env.project, instructionId)
266 : "");
267 const ownership = yield* createInternalLabels(id);
268 const displayName = yield* toDisplayName(
269 id,
270 news.displayName,
271 output?.displayName,
272 );
273 const description = encodeOwnership(ownership, news.description);
274
275 let current =
276 (yield* getByName(name)) ?? (yield* findByOwnership(id, env.project));
277
278 if (current === undefined) {
279 const created = yield* retryTransient(
280 datalabeling.createProjectsInstructions({
281 parent: projectParent(env.project),
282 body: {
283 instruction: {
284 displayName,
285 description,
286 dataType: news.dataType,
287 pdfInstruction: news.pdfInstruction,
288 csvInstruction: news.csvInstruction,
289 },
290 },
291 }),
292 ).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
293 if (created !== undefined) {
294 const done = yield* waitForOperation(created);
295 const createdName = resourceNameFromOperation(done);
296 if (createdName !== undefined) {
297 current = yield* waitForVisible(getByName(createdName));
298 }
299 }
300 if (current === undefined) {
301 current = yield* findByOwnership(id, env.project);
302 }
303 }
304
305 if (current === undefined) {
306 return yield* new InstructionNotResolved({
307 name: name || projectParent(env.project),
308 });
309 }
310
311 return toAttrs(current, env.project);
312 }),
313
314 delete: Effect.fn(function* ({ output }) {
315 if (!output.name) return;
316 yield* ignoreGone(
317 retryDelete(
318 datalabeling.deleteProjectsInstructions({ name: output.name }),
319 ),
320 );
321 yield* waitUntilGone(getByName(output.name));
322 }),
323 });