View Javadoc
1   package com.acumenvelocity.ath.controller;
2   
3   import java.net.URI;
4   import java.util.ArrayList;
5   import java.util.Date;
6   import java.util.List;
7   import java.util.UUID;
8   
9   import com.acumenvelocity.ath.common.AthUtil;
10  import com.acumenvelocity.ath.common.ControllerUtil;
11  import com.acumenvelocity.ath.common.Response;
12  import com.acumenvelocity.ath.model.ModelInfo;
13  import com.acumenvelocity.ath.model.ModelInfosWrapper;
14  import com.acumenvelocity.ath.model.PaginationInfo;
15  import com.acumenvelocity.ath.model.TrainModelRequest;
16  import com.acumenvelocity.ath.model.TrainingCompletedResponse;
17  import com.acumenvelocity.ath.model.TrainingFailedResponse;
18  import com.acumenvelocity.ath.model.TrainingJobResponse;
19  import com.acumenvelocity.ath.model.TrainingJobStatus;
20  import com.acumenvelocity.ath.model.TrainingStatusResponse;
21  import com.acumenvelocity.ath.service.AutoMlTrainingService;
22  import com.fasterxml.jackson.databind.JsonNode;
23  
24  import io.swagger.oas.inflector.models.RequestContext;
25  import io.swagger.oas.inflector.models.ResponseContext;
26  
27  /**
28   * REST Controller for handling AutoML custom model training operations.
29   * Provides endpoints for training custom translation models, checking training status,
30   * cancelling training jobs, and listing trained models.
31   * 
32   * <p>
33   * This controller manages the full lifecycle of AutoML training jobs:
34   * <ul>
35   * <li>Scanning GCS buckets for training files (TMX, TSV, CSV, TXT)</li>
36   * <li>Creating AutoML datasets</li>
37   * <li>Importing training data</li>
38   * <li>Training custom models</li>
39   * <li>Monitoring training progress</li>
40   * </ul>
41   * </p>
42   * 
43   * <p>
44   * All training operations are asynchronous and follow the same pattern as
45   * document import/export operations. Training job state is persisted in Solr
46   * (ath_automl_jobs core) for durability and multi-node coordination.
47   * </p>
48   * 
49   * @author Acumen Velocity
50   * @version 1.0
51   * @since 1.0
52   */
53  public class AutoMlController {
54  
55    private final AutoMlTrainingService trainingService;
56  
57    /**
58     * Constructor with dependency injection for the training service.
59     * 
60     * @param trainingService the service handling AutoML training operations
61     */
62    public AutoMlController(AutoMlTrainingService trainingService) {
63      this.trainingService = trainingService;
64    }
65  
66    /**
67     * Default constructor for Swagger Inflector.
68     * Initializes the training service with default configuration.
69     */
70    public AutoMlController() {
71      this.trainingService = new AutoMlTrainingService();
72    }
73  
74    /**
75     * Initiates an asynchronous AutoML model training job.
76     * 
77     * <p>
78     * This endpoint:
79     * <ol>
80     * <li>Validates the training request parameters</li>
81     * <li>Creates a training job record in Solr</li>
82     * <li>Initiates background processing</li>
83     * <li>Returns immediately with 202 Accepted</li>
84     * </ol>
85     * </p>
86     * 
87     * <p>
88     * <b>Training Process:</b>
89     * </p>
90     * <ul>
91     * <li>SCANNING_BUCKETS: Recursively scanning GCS buckets for files</li>
92     * <li>PREPARING_DATASET: Creating AutoML dataset resource</li>
93     * <li>IMPORTING_DATA: Importing training files into dataset</li>
94     * <li>TRAINING: Training the custom model (takes several hours)</li>
95     * <li>COMPLETED: Model ready for use</li>
96     * <li>FAILED: Training failed with error details</li>
97     * </ul>
98     * 
99     * <p>
100    * <b>File Format Support:</b>
101    * Automatically detects and processes TMX, TSV, CSV, and TXT files.
102    * </p>
103    * 
104    * <p>
105    * <b>Data Validation:</b>
106    * </p>
107    * <ul>
108    * <li>Minimum segments: 1,000 (returns 400 Bad Request if less)</li>
109    * <li>Maximum segments: 15,000,000 (returns 400 Bad Request if more)</li>
110    * <li>Automatic 80/10/10 split for train/validation/test</li>
111    * </ul>
112    *
113    * @param request       the HTTP request context from Swagger Inflector
114    * @param trainingJobId UUID for the training job (provided by client)
115    * @param modelName     display name for the trained model
116    * @param srcLang       source language ISO code (e.g., "en")
117    * @param trgLang       target language ISO code (e.g., "es")
118    * @param gcsBuckets    list of GCS bucket URIs to scan
119    * @param projectId     GCP project ID for AutoML resources
120    * @param location      GCP region for AutoML (e.g., "us-central1")
121    * @param userId        UUID of the user initiating training
122    * @return ResponseContext with 202 Accepted and status URL, or 400/409/500 on error
123    * 
124    * @apiNote POST /automl/model/train
125    */
126   public ResponseContext trainModel(RequestContext request, JsonNode bodyNode) {
127     try {
128       if (!ControllerUtil.checkParam(bodyNode)) {
129         return Response.error(400, "Invalid request parameter, bodyNode is null");
130       }
131       
132       TrainModelRequest body = AthUtil.safeFromJsonNode(bodyNode,
133           TrainModelRequest.class, null);
134 
135       if (body == null) {
136         return Response.error(400, "Invalid request body");
137       }
138       
139       UUID trainingJobId = body.getTrainingJobId();
140       String modelName = body.getModelName();
141       String srcLang = body.getSrcLang();
142       String trgLang = body.getTrgLang();
143       List<URI> gcsBuckets = body.getGcsBuckets();
144       String projectId = body.getProjectId();
145       String location = body.getLocation();
146       UUID userId = body.getUserId();
147 
148       if (!ControllerUtil.checkParam(trainingJobId)) {
149         return Response.error(400, "training_job_id is required");
150       }
151 
152       if (modelName == null || modelName.trim().isEmpty()) {
153         return Response.error(400, "model_name is required");
154       }
155 
156       if (srcLang == null || srcLang.trim().isEmpty()) {
157         return Response.error(400, "src_lang is required");
158       }
159 
160       if (trgLang == null || trgLang.trim().isEmpty()) {
161         return Response.error(400, "trg_lang is required");
162       }
163 
164       if (gcsBuckets == null || gcsBuckets.isEmpty()) {
165         return Response.error(400, "gcs_buckets list is required and cannot be empty");
166       }
167 
168       if (projectId == null || projectId.trim().isEmpty()) {
169         return Response.error(400, "project_id is required");
170       }
171 
172       if (location == null || location.trim().isEmpty()) {
173         return Response.error(400, "location is required");
174       }
175 
176       if (!ControllerUtil.checkParam(userId)) {
177         return Response.error(400, "user_id is required");
178       }
179 
180       // Validate GCS bucket URIs
181       for (URI bucketUri : gcsBuckets) {
182         if (!bucketUri.toString().startsWith("gs://")) {
183           return Response.error(400, "Invalid GCS bucket URI: " + bucketUri +
184               ". Must start with gs://");
185         }
186       }
187 
188       // Check if training job already exists
189       if (trainingService.trainingJobExists(trainingJobId)) {
190         return Response.error(409, "Training job with ID " + trainingJobId +
191             " already exists");
192       }
193 
194       // Initiate asynchronous training
195       trainingService.initiateTraining(
196           trainingJobId,
197           modelName,
198           srcLang,
199           trgLang,
200           gcsBuckets,
201           projectId,
202           location,
203           userId);
204 
205       // Return 202 Accepted with status URL
206       TrainingJobResponse response = new TrainingJobResponse()
207           .status(TrainingJobResponse.StatusEnum.SCANNING_BUCKETS)
208           .trainingJobId(trainingJobId)
209           .statusUrl("/automl/model/train/" + trainingJobId + "/status")
210           .submittedAt(new Date());
211 
212       ResponseContext responseContext = new ResponseContext()
213           .status(202)
214           .entity(response);
215 
216       responseContext.getHeaders().put("Location",
217           List.of("/automl/model/train/" + trainingJobId + "/status"));
218 
219       return responseContext;
220 
221     } catch (IllegalArgumentException e) {
222       return Response.error(400, e.getMessage());
223 
224     } catch (Exception e) {
225       return Response.error(500, e, "Error initiating model training");
226     }
227   }
228 
229   /**
230    * Retrieves the current status of an AutoML training job.
231    * 
232    * <p>
233    * <b>Response Codes:</b>
234    * </p>
235    * <ul>
236    * <li><b>202 Accepted:</b> Training in progress</li>
237    * <li><b>200 OK:</b> Training completed successfully</li>
238    * <li><b>500 Internal Server Error:</b> Training failed</li>
239    * <li><b>404 Not Found:</b> Training job not found</li>
240    * </ul>
241    * 
242    * <p>
243    * <b>Status Values:</b>
244    * </p>
245    * <ul>
246    * <li>SCANNING_BUCKETS: Scanning GCS buckets for training files</li>
247    * <li>PREPARING_DATASET: Creating AutoML dataset</li>
248    * <li>IMPORTING_DATA: Importing files to dataset</li>
249    * <li>TRAINING: Training model (longest phase, typically hours)</li>
250    * <li>COMPLETED: Training successful, model ready</li>
251    * <li>FAILED: Training failed, see error details</li>
252    * </ul>
253    * 
254    * <p>
255    * <b>Polling Recommendations:</b>
256    * </p>
257    * <ul>
258    * <li>SCANNING_BUCKETS / PREPARING_DATASET: Poll every 5-10 seconds</li>
259    * <li>IMPORTING_DATA: Poll every 30-60 seconds</li>
260    * <li>TRAINING: Poll every 5-10 minutes (training takes hours)</li>
261    * </ul>
262    *
263    * @param request       the HTTP request context from Swagger Inflector
264    * @param trainingJobId UUID of the training job
265    * @return ResponseContext with current status (202, 200, 404, or 500)
266    * 
267    * @apiNote GET /automl/model/train/{training_job_id}/status
268    */
269   public ResponseContext getTrainingStatus(RequestContext request, UUID trainingJobId) {
270     try {
271       if (!ControllerUtil.checkParam(trainingJobId)) {
272         return Response.error(400, "Invalid training_job_id format");
273       }
274 
275       // Check if training job exists
276       if (!trainingService.trainingJobExists(trainingJobId)) {
277         return Response.error(404, "Training job not found: " + trainingJobId);
278       }
279 
280       // Get current status
281       TrainingJobStatus status = trainingService.getTrainingStatus(trainingJobId);
282 
283       if (status == null) {
284         return Response.error(404, "Training job not found: " + trainingJobId);
285       }
286 
287       switch (status) {
288       case SCANNING_BUCKETS:
289       case PREPARING_DATASET:
290       case IMPORTING_DATA:
291       case TRAINING:
292         // Training in progress - return 202 Accepted
293         TrainingStatusResponse inProgressResponse = new TrainingStatusResponse()
294             .status(TrainingStatusResponse.StatusEnum.fromValue(status.name()))
295             .trainingJobId(trainingJobId)
296             .progress(trainingService.getProgress(trainingJobId))
297             .startedAt(trainingService.getStartedAt(trainingJobId))
298             .currentPhase(trainingService.getCurrentPhase(trainingJobId));
299 
300         ResponseContext inProgressContext = new ResponseContext()
301             .status(202)
302             .entity(inProgressResponse);
303 
304         // Add Retry-After header based on current phase
305         int retryAfter = getRetryAfterSeconds(status);
306         inProgressContext.getHeaders().put("Retry-After", List.of(String.valueOf(retryAfter)));
307 
308         return inProgressContext;
309 
310       case COMPLETED:
311         // Training completed - return 200 OK
312         TrainingCompletedResponse completedResponse = new TrainingCompletedResponse()
313             .status(TrainingCompletedResponse.StatusEnum.COMPLETED)
314             .trainingJobId(trainingJobId)
315             .modelId(trainingService.getModelId(trainingJobId))
316             .modelName(trainingService.getModelName(trainingJobId))
317             .datasetId(trainingService.getDatasetId(trainingJobId))
318             .completedAt(trainingService.getCompletedAt(trainingJobId))
319             .datasetStats(trainingService.getDatasetStats(trainingJobId));
320 
321         return new ResponseContext()
322             .status(200)
323             .entity(completedResponse);
324 
325       case FAILED:
326         // Training failed - return 500 Internal Server Error
327         TrainingFailedResponse failedResponse = new TrainingFailedResponse()
328             .status(TrainingFailedResponse.StatusEnum.FAILED)
329             .trainingJobId(trainingJobId)
330             .errorType(trainingService.getErrorType(trainingJobId))
331             .errorMessage(trainingService.getErrorMessage(trainingJobId))
332             .failedAt(trainingService.getFailedAt(trainingJobId));
333 
334         return new ResponseContext()
335             .status(500)
336             .entity(failedResponse);
337 
338       default:
339         return Response.error(500, "Unknown training status: " + status);
340       }
341 
342     } catch (Exception e) {
343       return Response.error(500, e, "Error retrieving training status");
344     }
345   }
346 
347   /**
348    * Cancels an in-progress AutoML training job.
349    * 
350    * <p>
351    * <b>Cancellation Rules:</b>
352    * </p>
353    * <ul>
354    * <li>Can cancel: SCANNING_BUCKETS, PREPARING_DATASET, IMPORTING_DATA, TRAINING</li>
355    * <li>Cannot cancel: COMPLETED, FAILED (returns 409 Conflict)</li>
356    * <li>Only the user who initiated the job can cancel it (403 Forbidden otherwise)</li>
357    * <li>Once cancelled, the job cannot be resumed</li>
358    * <li>Any created AutoML resources (dataset) may remain and should be cleaned up separately</li>
359    * </ul>
360    * 
361    * <p>
362    * <b>Best Practices:</b>
363    * </p>
364    * <ul>
365    * <li>Check status before cancelling to avoid 409 errors</li>
366    * <li>Consider cost implications of cancelling long-running training</li>
367    * <li>Clean up any orphaned AutoML datasets if needed</li>
368    * </ul>
369    *
370    * @param request       the HTTP request context from Swagger Inflector
371    * @param trainingJobId UUID of the training job to cancel
372    * @return ResponseContext with 204 No Content on success, 403/404/409/500 on error
373    * 
374    * @apiNote DELETE /automl/model/train/{training_job_id}
375    */
376   public ResponseContext cancelTraining(RequestContext request, UUID trainingJobId) {
377     try {
378       // Validate trainingJobId
379       if (!ControllerUtil.checkParam(trainingJobId)) {
380         return Response.error(400, "Invalid training_job_id format");
381       }
382 
383       // Check if training job exists
384       if (!trainingService.trainingJobExists(trainingJobId)) {
385         return Response.error(404, "Training job not found: " + trainingJobId);
386       }
387 
388       // Get current status
389       TrainingJobStatus status = trainingService.getTrainingStatus(trainingJobId);
390 
391       // Check if training can be cancelled
392       if (TrainingJobStatus.COMPLETED.equals(status)) {
393         return Response.error(409,
394             "Cannot cancel training - training has already completed successfully");
395       }
396 
397       if (TrainingJobStatus.FAILED.equals(status)) {
398         return Response.error(409,
399             "Cannot cancel training - training has already failed");
400       }
401 
402       if (TrainingJobStatus.CANCELLED.equals(status)) {
403         return Response.error(409,
404             "Cannot cancel training - training has already been cancelled");
405       }
406 
407       // Cancel the training job
408       trainingService.cancelTraining(trainingJobId);
409 
410       // Return 204 No Content
411       return new ResponseContext().status(204);
412 
413     } catch (Exception e) {
414       return Response.error(500, e, "Error cancelling training job");
415     }
416   }
417 
418   /**
419    * Retrieves a paginated list of trained AutoML models.
420    * 
421    * <p>
422    * <b>Pagination Details:</b>
423    * </p>
424    * <ul>
425    * <li>Default page size: 10 items</li>
426    * <li>Maximum page size: 100 items</li>
427    * <li>Page numbers start from 1</li>
428    * <li>Automatically adjusts invalid page numbers</li>
429    * </ul>
430    * 
431    * <p>
432    * Returns all successfully trained models, sorted by creation date (newest first).
433    * Failed or cancelled training jobs are not included in the list.
434    * </p>
435    *
436    * @param request  the HTTP request context from Swagger Inflector
437    * @param page     the requested page number (1-based, optional)
438    * @param pageSize the number of items per page (1-100, optional)
439    * @return ResponseContext containing paginated model list or error details
440    * 
441    * @apiNote GET /automl/models
442    */
443   public ResponseContext getModels(RequestContext request, Integer page, Integer pageSize) {
444     try {
445       List<ModelInfo> allModels = trainingService.getAllModels();
446       ModelInfosWrapper wrapper = new ModelInfosWrapper();
447 
448       // Empty result
449       if (allModels.isEmpty()) {
450         wrapper.models(new ArrayList<>())
451             .pagination(new PaginationInfo()
452                 .page(1)
453                 .pageSize(0)
454                 .totalItems(0L)
455                 .totalPages(0)
456                 .hasNext(false)
457                 .hasPrevious(false));
458 
459         return Response.success(200, wrapper);
460       }
461 
462       // Determine pagination parameters
463       long totalItems = allModels.size();
464       int size = (pageSize != null) ? Math.max(1, Math.min(100, pageSize)) : (int) totalItems;
465       int totalPages = (int) Math.ceil((double) totalItems / size);
466 
467       int pageNum;
468       List<ModelInfo> resultModels;
469 
470       if (page == null && pageSize == null) {
471         // No pagination requested → return all as page 1
472         pageNum = 1;
473         resultModels = allModels;
474         totalPages = 1;
475 
476       } else {
477         // Pagination requested
478         pageNum = (page != null) ? Math.max(1, Math.min(page, Math.max(1, totalPages))) : 1;
479         int start = (pageNum - 1) * size;
480         int end = Math.min(start + size, allModels.size());
481         resultModels = allModels.subList(start, end);
482       }
483 
484       // Build pagination info
485       PaginationInfo pagination = new PaginationInfo()
486           .page(pageNum)
487           .pageSize(size)
488           .totalItems(totalItems)
489           .totalPages(totalPages)
490           .hasNext(pageNum < totalPages)
491           .hasPrevious(pageNum > 1);
492 
493       wrapper.models(resultModels)
494           .pagination(pagination);
495 
496       return Response.success(200, wrapper);
497 
498     } catch (Exception e) {
499       return Response.error(500, e, "Error fetching trained models");
500     }
501   }
502 
503   /**
504    * Determines the suggested retry interval based on training phase.
505    * 
506    * @param status current training status
507    * @return recommended seconds to wait before next poll
508    */
509   private int getRetryAfterSeconds(TrainingJobStatus status) {
510     switch (status) {
511     case SCANNING_BUCKETS:
512     case PREPARING_DATASET:
513       return 10; // Fast phases - check every 10 seconds
514 
515     case IMPORTING_DATA:
516       return 60; // Medium phase - check every minute
517 
518     case TRAINING:
519       return 300; // Slow phase - check every 5 minutes
520 
521     default:
522       return 30; // Default to 30 seconds
523     }
524   }
525 }