View Javadoc
1   package com.acumenvelocity.ath.service;
2   
3   import java.io.IOException;
4   import java.net.URI;
5   import java.util.ArrayList;
6   import java.util.Date;
7   import java.util.List;
8   import java.util.Map;
9   import java.util.UUID;
10  import java.util.concurrent.ExecutorService;
11  import java.util.concurrent.Executors;
12  import java.util.stream.Collectors;
13  
14  import org.apache.solr.client.solrj.SolrClient;
15  import org.apache.solr.client.solrj.SolrQuery;
16  import org.apache.solr.client.solrj.SolrServerException;
17  import org.apache.solr.client.solrj.response.QueryResponse;
18  import org.apache.solr.common.SolrDocument;
19  import org.apache.solr.common.SolrDocumentList;
20  import org.apache.solr.common.SolrInputDocument;
21  
22  import com.acumenvelocity.ath.common.Const;
23  import com.acumenvelocity.ath.gcs.AthStorage;
24  import com.acumenvelocity.ath.model.DatasetStats;
25  import com.acumenvelocity.ath.model.ModelInfo;
26  import com.acumenvelocity.ath.model.TrainingFailedResponse.ErrorTypeEnum;
27  import com.acumenvelocity.ath.model.TrainingJobStatus;
28  import com.acumenvelocity.ath.solr.AthIndex;
29  import com.google.api.gax.longrunning.OperationFuture;
30  import com.google.cloud.storage.Blob;
31  import com.google.cloud.storage.Storage;
32  import com.google.cloud.translate.v3.CreateDatasetRequest;
33  import com.google.cloud.translate.v3.CreateModelRequest;
34  import com.google.cloud.translate.v3.Dataset;
35  import com.google.cloud.translate.v3.DatasetInputConfig;
36  import com.google.cloud.translate.v3.GcsInputSource;
37  import com.google.cloud.translate.v3.ImportDataRequest;
38  import com.google.cloud.translate.v3.LocationName;
39  import com.google.cloud.translate.v3.Model;
40  import com.google.cloud.translate.v3.TranslationServiceClient;
41  import com.google.protobuf.Empty;
42  
43  /**
44   * Service layer for AutoML training operations with Solr persistence.
45   * Handles the complete training lifecycle including:
46   * <ul>
47   * <li>GCS bucket scanning for training files</li>
48   * <li>AutoML dataset creation and data import</li>
49   * <li>Model training orchestration</li>
50   * <li>Training job status tracking and persistence in Solr</li>
51   * </ul>
52   * 
53   * <p>
54   * This service manages asynchronous training jobs using a thread pool executor.
55   * Each training job progresses through the following states:
56   * </p>
57   * 
58   * <pre>
59   * SCANNING_BUCKETS → PREPARING_DATASET → IMPORTING_DATA → TRAINING → COMPLETED/FAILED
60   * </pre>
61   * 
62   * <p>
63   * <b>AutoML Limits:</b>
64   * </p>
65   * <ul>
66   * <li>Minimum segments: 1,000</li>
67   * <li>Maximum segments: 15,000,000</li>
68   * <li>Supported file formats: TMX, TSV, CSV, TXT</li>
69   * <li>Automatic 80/10/10 train/validation/test split</li>
70   * </ul>
71   * 
72   * <p>
73   * <b>Persistence:</b>
74   * Training job state is persisted in Solr (ath_automl_jobs core) for:
75   * <ul>
76   * <li>Durability across service restarts</li>
77   * <li>Multi-node coordination</li>
78   * <li>Status queries and reporting</li>
79   * </ul>
80   * </p>
81   * 
82   * @author Acumen Velocity
83   * @version 1.0
84   * @since 1.0
85   */
86  public class AutoMlTrainingService {
87  
88    private static final int MIN_SEGMENTS = 1_000;
89    private static final int MAX_SEGMENTS = 15_000_000;
90  
91    private static final List<String> SUPPORTED_EXTENSIONS = List.of(
92        ".tmx", ".tsv", ".csv", ".txt");
93  
94    private final ExecutorService executorService;
95    private final Storage storageClient;
96    private final SolrClient solrClient;
97  
98    /**
99     * Converts a Solr document to a ModelInfo object.
100    * 
101    * @param doc the Solr document
102    * @return ModelInfo object
103    */
104   private ModelInfo documentToModelInfo(SolrDocument doc) {
105     ModelInfo info = new ModelInfo();
106 
107     String trainingJobId = (String) doc.getFieldValue("trainingJobId");
108     info.trainingJobId(trainingJobId != null ? UUID.fromString(trainingJobId) : null);
109 
110     info.modelId((String) doc.getFieldValue("modelId"));
111     info.modelName((String) doc.getFieldValue("modelName"));
112     info.datasetId((String) doc.getFieldValue("datasetId"));
113     info.srcLang((String) doc.getFieldValue("srcLang"));
114     info.trgLang((String) doc.getFieldValue("trgLang"));
115     info.createdAt((Date) doc.getFieldValue("completedAt"));
116 
117     // Build dataset stats if available
118     Integer filesProcessed = (Integer) doc.getFieldValue("filesProcessed");
119     Long totalSegments = (Long) doc.getFieldValue("totalSegments");
120 
121     if (filesProcessed != null && totalSegments != null) {
122       DatasetStats stats = new DatasetStats()
123           .filesProcessed(filesProcessed)
124           .totalSegments(totalSegments)
125           .trainSegments((Long) doc.getFieldValue("trainSegments"))
126           .validationSegments((Long) doc.getFieldValue("validationSegments"))
127           .testSegments((Long) doc.getFieldValue("testSegments"));
128 
129       info.datasetStats(stats);
130     }
131 
132     return info;
133   }
134 
135   /**
136    * Internal class to hold training job context for async execution.
137    */
138   private static class TrainingJobContext {
139     final UUID trainingJobId;
140     final String modelName;
141     final String srcLang;
142     final String trgLang;
143     final List<URI> gcsBuckets;
144     final String projectId;
145     final String location;
146     // final UUID userId;
147 
148     TrainingJobContext(UUID trainingJobId, String modelName, String srcLang, String trgLang,
149         List<URI> gcsBuckets, String projectId, String location, UUID userId) {
150       this.trainingJobId = trainingJobId;
151       this.modelName = modelName;
152       this.srcLang = srcLang;
153       this.trgLang = trgLang;
154       this.gcsBuckets = gcsBuckets;
155       this.projectId = projectId;
156       this.location = location;
157       // this.userId = userId;
158     }
159   }
160 
161   /*
162    * Default constructor.
163    * Initializes storage client, Solr client, and thread pool for async operations.
164    */
165   public AutoMlTrainingService() {
166     this.executorService = Executors.newFixedThreadPool(5);
167     this.storageClient = AthStorage.getGcs();
168     this.solrClient = AthIndex.getSolr().getClient();
169   }
170 
171   /**
172    * Constructor with dependency injection for testing.
173    * 
174    * @param solrClient    the Solr client to use
175    * @param storageClient the GCS storage client to use
176    */
177   public AutoMlTrainingService(SolrClient solrClient, Storage storageClient) {
178     this.executorService = Executors.newFixedThreadPool(5);
179     this.storageClient = storageClient;
180     this.solrClient = solrClient;
181   }
182 
183   /**
184    * Checks if a training job with the given ID exists in Solr.
185    * 
186    * @param trainingJobId the training job UUID
187    * @return true if the job exists, false otherwise
188    */
189   public boolean trainingJobExists(UUID trainingJobId) {
190     try {
191       SolrQuery query = new SolrQuery("trainingJobId:" + trainingJobId.toString());
192       QueryResponse response = solrClient.query(Const.SOLR_CORE_ATH_AUTOML_JOBS, query);
193       return response.getResults().getNumFound() > 0;
194 
195     } catch (Exception e) {
196       throw new RuntimeException("Error checking if training job exists", e);
197     }
198   }
199 
200   /**
201    * Initiates an asynchronous AutoML training job.
202    * 
203    * <p>
204    * This method:
205    * <ol>
206    * <li>Creates a training job record in Solr</li>
207    * <li>Submits the job to the executor service</li>
208    * <li>Returns immediately (non-blocking)</li>
209    * </ol>
210    * </p>
211    * 
212    * <p>
213    * The training process runs asynchronously and updates the job status in Solr as it progresses.
214    * </p>
215    * 
216    * @param trainingJobId UUID for the training job
217    * @param modelName     display name for the model
218    * @param srcLang       source language ISO code
219    * @param trgLang       target language ISO code
220    * @param gcsBuckets    list of GCS bucket URIs to scan
221    * @param projectId     GCP project ID
222    * @param location      GCP region (e.g., "us-central1")
223    * @param userId        UUID of the user initiating training
224    * @throws IllegalArgumentException if segment count violates AutoML limits
225    */
226   public void initiateTraining(
227       UUID trainingJobId,
228       String modelName,
229       String srcLang,
230       String trgLang,
231       List<URI> gcsBuckets,
232       String projectId,
233       String location,
234       UUID userId) {
235 
236     try {
237       // Create initial training job document in Solr
238       SolrInputDocument doc = new SolrInputDocument();
239       doc.addField("trainingJobId", trainingJobId.toString());
240       doc.addField("modelName", modelName);
241       doc.addField("srcLang", srcLang);
242       doc.addField("trgLang", trgLang);
243       doc.addField("projectId", projectId);
244       doc.addField("location", location);
245       doc.addField("createdBy", userId.toString());
246       doc.addField("createdAt", new Date());
247       doc.addField("startedAt", new Date());
248       doc.addField("status", TrainingJobStatus.SCANNING_BUCKETS.name());
249       doc.addField("progress", 0);
250       doc.addField("currentPhase", "Scanning GCS buckets for training files");
251 
252       // Add GCS buckets as multi-valued field
253       for (URI bucket : gcsBuckets) {
254         doc.addField("gcsBuckets", bucket.toString());
255       }
256 
257       solrClient.add(Const.SOLR_CORE_ATH_AUTOML_JOBS, doc);
258       solrClient.commit(Const.SOLR_CORE_ATH_AUTOML_JOBS);
259 
260       // Submit async training task
261       TrainingJobContext context = new TrainingJobContext(
262           trainingJobId, modelName, srcLang, trgLang, gcsBuckets, projectId, location, userId);
263 
264       executorService.submit(() -> executeTraining(context));
265 
266     } catch (Exception e) {
267       throw new RuntimeException("Error initiating training job", e);
268     }
269   }
270 
271   /**
272    * Gets the current status of a training job from Solr.
273    * 
274    * @param trainingJobId the training job UUID
275    * @return current status enum, or null if job not found
276    */
277   public TrainingJobStatus getTrainingStatus(UUID trainingJobId) {
278     try {
279       SolrDocument doc = getJobDocument(trainingJobId);
280       if (doc == null) {
281         return null;
282       }
283 
284       String statusStr = (String) doc.getFieldValue("status");
285       return statusStr != null ? TrainingJobStatus.valueOf(statusStr) : null;
286 
287     } catch (Exception e) {
288       throw new RuntimeException("Error getting training status", e);
289     }
290   }
291 
292   /**
293    * Gets the progress percentage of a training job.
294    * 
295    * @param trainingJobId the training job UUID
296    * @return progress percentage (0-100)
297    */
298   public int getProgress(UUID trainingJobId) {
299     try {
300       SolrDocument doc = getJobDocument(trainingJobId);
301       Integer progress = (Integer) doc.getFieldValue("progress");
302       return progress != null ? progress : 0;
303 
304     } catch (Exception e) {
305       return 0;
306     }
307   }
308 
309   /**
310    * Gets the start timestamp of a training job.
311    * 
312    * @param trainingJobId the training job UUID
313    * @return start date
314    */
315   public Date getStartedAt(UUID trainingJobId) {
316     try {
317       SolrDocument doc = getJobDocument(trainingJobId);
318       return (Date) doc.getFieldValue("startedAt");
319 
320     } catch (Exception e) {
321       return null;
322     }
323   }
324 
325   /**
326    * Gets the current phase description of a training job.
327    * 
328    * @param trainingJobId the training job UUID
329    * @return human-readable phase description
330    */
331   public String getCurrentPhase(UUID trainingJobId) {
332     try {
333       SolrDocument doc = getJobDocument(trainingJobId);
334       return (String) doc.getFieldValue("currentPhase");
335 
336     } catch (Exception e) {
337       return null;
338     }
339   }
340 
341   /**
342    * Gets the AutoML model ID for a completed training job.
343    * 
344    * @param trainingJobId the training job UUID
345    * @return AutoML model resource name
346    */
347   public String getModelId(UUID trainingJobId) {
348     try {
349       SolrDocument doc = getJobDocument(trainingJobId);
350       return (String) doc.getFieldValue("modelId");
351 
352     } catch (Exception e) {
353       return null;
354     }
355   }
356 
357   /**
358    * Gets the model name for a completed training job.
359    * 
360    * @param trainingJobId the training job UUID
361    * @return model display name
362    */
363   public String getModelName(UUID trainingJobId) {
364     try {
365       SolrDocument doc = getJobDocument(trainingJobId);
366       return (String) doc.getFieldValue("modelName");
367 
368     } catch (Exception e) {
369       return null;
370     }
371   }
372 
373   /**
374    * Gets the AutoML dataset ID for a training job.
375    * 
376    * @param trainingJobId the training job UUID
377    * @return AutoML dataset resource name
378    */
379   public String getDatasetId(UUID trainingJobId) {
380     try {
381       SolrDocument doc = getJobDocument(trainingJobId);
382       return (String) doc.getFieldValue("datasetId");
383 
384     } catch (Exception e) {
385       return null;
386     }
387   }
388 
389   /**
390    * Gets the completion timestamp for a training job.
391    * 
392    * @param trainingJobId the training job UUID
393    * @return completion date
394    */
395   public Date getCompletedAt(UUID trainingJobId) {
396     try {
397       SolrDocument doc = getJobDocument(trainingJobId);
398       return (Date) doc.getFieldValue("completedAt");
399 
400     } catch (Exception e) {
401       return null;
402     }
403   }
404 
405   /**
406    * Gets the dataset statistics for a training job.
407    * 
408    * @param trainingJobId the training job UUID
409    * @return dataset statistics object
410    */
411   public DatasetStats getDatasetStats(UUID trainingJobId) {
412     try {
413       SolrDocument doc = getJobDocument(trainingJobId);
414       if (doc == null) {
415         return null;
416       }
417 
418       Integer filesProcessed = (Integer) doc.getFieldValue("filesProcessed");
419       Long totalSegments = (Long) doc.getFieldValue("totalSegments");
420       Long trainSegments = (Long) doc.getFieldValue("trainSegments");
421       Long validationSegments = (Long) doc.getFieldValue("validationSegments");
422       Long testSegments = (Long) doc.getFieldValue("testSegments");
423 
424       if (filesProcessed == null || totalSegments == null) {
425         return null;
426       }
427 
428       return new DatasetStats()
429           .filesProcessed(filesProcessed)
430           .totalSegments(totalSegments)
431           .trainSegments(trainSegments)
432           .validationSegments(validationSegments)
433           .testSegments(testSegments);
434 
435     } catch (Exception e) {
436       return null;
437     }
438   }
439 
440   /**
441    * Gets the error type for a failed training job.
442    * 
443    * @param trainingJobId the training job UUID
444    * @return error type enum
445    */
446   public ErrorTypeEnum getErrorType(UUID trainingJobId) {
447     try {
448       SolrDocument doc = getJobDocument(trainingJobId);
449       String errorType = (String) doc.getFieldValue("errorType");
450       return errorType != null ? ErrorTypeEnum.valueOf(errorType) : null;
451 
452     } catch (Exception e) {
453       return null;
454     }
455   }
456 
457   /**
458    * Gets the error message for a failed training job.
459    * 
460    * @param trainingJobId the training job UUID
461    * @return error message string
462    */
463   public String getErrorMessage(UUID trainingJobId) {
464     try {
465       SolrDocument doc = getJobDocument(trainingJobId);
466       return (String) doc.getFieldValue("errorMessage");
467 
468     } catch (Exception e) {
469       return null;
470     }
471   }
472 
473   /**
474    * Gets the failure timestamp for a failed training job.
475    * 
476    * @param trainingJobId the training job UUID
477    * @return failure date
478    */
479   public Date getFailedAt(UUID trainingJobId) {
480     try {
481       SolrDocument doc = getJobDocument(trainingJobId);
482       return (Date) doc.getFieldValue("failedAt");
483 
484     } catch (Exception e) {
485       return null;
486     }
487   }
488 
489   /**
490    * Returns the user ID who owns (initiated) this training job.
491    * 
492    * @param trainingJobId the training job UUID
493    * @return user UUID who created the job
494    */
495   public UUID getJobOwnerId(UUID trainingJobId) {
496     try {
497       SolrDocument doc = getJobDocument(trainingJobId);
498       String userId = (String) doc.getFieldValue("createdBy");
499       return userId != null ? UUID.fromString(userId) : null;
500 
501     } catch (Exception e) {
502       return null;
503     }
504   }
505 
506   /**
507    * Cancels an in-progress training job.
508    * 
509    * @param trainingJobId the training job UUID
510    */
511   public void cancelTraining(UUID trainingJobId) {
512     try {
513       SolrInputDocument doc = new SolrInputDocument();
514       doc.addField("trainingJobId", trainingJobId.toString());
515 
516       // Partial update - only set status to CANCELLED
517       doc.addField("status", Map.of("set", TrainingJobStatus.CANCELLED.name()));
518 
519       solrClient.add(Const.SOLR_CORE_ATH_AUTOML_JOBS, doc);
520       solrClient.commit(Const.SOLR_CORE_ATH_AUTOML_JOBS);
521 
522       // Note: Actual AutoML operation cancellation would be implemented by
523       // checking the cancelled status in the training loop
524 
525     } catch (Exception e) {
526       throw new RuntimeException("Error cancelling training job", e);
527     }
528   }
529 
530   /**
531    * Gets a list of all successfully trained models from Solr.
532    * 
533    * @return list of model information objects
534    */
535   public List<ModelInfo> getAllModels() {
536     try {
537       SolrQuery query = new SolrQuery("status:" + TrainingJobStatus.COMPLETED.name());
538       query.setSort("completedAt", SolrQuery.ORDER.desc); // Newest first
539       query.setRows(1000); // Max results
540 
541       QueryResponse response = solrClient.query(Const.SOLR_CORE_ATH_AUTOML_JOBS, query);
542       SolrDocumentList docs = response.getResults();
543 
544       return docs.stream()
545           .map(this::documentToModelInfo)
546           .collect(Collectors.toList());
547 
548     } catch (Exception e) {
549       throw new RuntimeException("Error fetching models", e);
550     }
551   }
552 
553   /**
554    * Executes the complete training workflow asynchronously.
555    * Updates Solr at each phase.
556    * 
557    * @param context the training job context
558    */
559   private void executeTraining(TrainingJobContext context) {
560     try {
561       // Phase 1: Scan GCS buckets for training files
562       updateJobStatus(context.trainingJobId, TrainingJobStatus.SCANNING_BUCKETS, 10,
563           "Scanning GCS buckets for training files");
564 
565       List<String> trainingFiles = scanGcsBuckets(context.gcsBuckets);
566 
567       if (trainingFiles.isEmpty()) {
568         failJob(context.trainingJobId, ErrorTypeEnum.INVALID_FILES,
569             "No valid training files found in specified buckets");
570         return;
571       }
572 
573       // Estimate segment count
574       int estimatedSegments = trainingFiles.size() * 1000;
575 
576       if (estimatedSegments < MIN_SEGMENTS) {
577         failJob(context.trainingJobId, ErrorTypeEnum.INSUFFICIENT_DATA,
578             "Dataset contains approximately " + estimatedSegments +
579                 " segments. Minimum required: " + MIN_SEGMENTS);
580         return;
581       }
582 
583       if (estimatedSegments > MAX_SEGMENTS) {
584         failJob(context.trainingJobId, ErrorTypeEnum.EXCESSIVE_DATA,
585             "Dataset contains approximately " + estimatedSegments +
586                 " segments. Maximum allowed: " + MAX_SEGMENTS);
587         return;
588       }
589 
590       // Check if cancelled
591       if (isCancelled(context.trainingJobId)) {
592         return;
593       }
594 
595       // Phase 2: Create AutoML dataset
596       updateJobStatus(context.trainingJobId, TrainingJobStatus.PREPARING_DATASET, 25,
597           "Creating AutoML dataset");
598 
599       String datasetId = createAutoMLDataset(context);
600       updateDatasetId(context.trainingJobId, datasetId);
601 
602       if (isCancelled(context.trainingJobId)) {
603         return;
604       }
605 
606       // Phase 3: Import training data
607       updateJobStatus(context.trainingJobId, TrainingJobStatus.IMPORTING_DATA, 40,
608           "Importing " + trainingFiles.size() + " files to dataset");
609 
610       importDataToDataset(context, datasetId, trainingFiles);
611 
612       if (isCancelled(context.trainingJobId)) {
613         return;
614       }
615 
616       // Phase 4: Train model
617       updateJobStatus(context.trainingJobId, TrainingJobStatus.TRAINING, 60,
618           "Training AutoML model");
619 
620       String modelId = trainAutoMLModel(context, datasetId);
621 
622       // Phase 5: Complete
623       completeJob(context.trainingJobId, modelId, trainingFiles.size(), estimatedSegments);
624 
625     } catch (InterruptedException e) {
626       // Training was cancelled
627       Thread.currentThread().interrupt();
628 
629     } catch (Exception e) {
630       failJob(context.trainingJobId, ErrorTypeEnum.TRAINING_ERROR,
631           "AutoML training failed: " + e.getMessage());
632     }
633   }
634 
635   /**
636    * Scans GCS buckets recursively to find supported training files.
637    * 
638    * @param gcsBuckets list of GCS bucket URIs
639    * @return list of GCS URIs for training files
640    */
641   private List<String> scanGcsBuckets(List<URI> gcsBuckets) {
642     List<String> trainingFiles = new ArrayList<>();
643 
644     for (URI bucketUri : gcsBuckets) {
645       String[] parts = bucketUri.toString().replace("gs://", "").split("/", 2);
646       String bucketName = parts[0];
647       String prefix = parts.length > 1 ? parts[1] : "";
648 
649       for (Blob blob : storageClient.list(bucketName,
650           Storage.BlobListOption.prefix(prefix)).iterateAll()) {
651 
652         String fileName = blob.getName().toLowerCase();
653 
654         if (SUPPORTED_EXTENSIONS.stream().anyMatch(fileName::endsWith)) {
655           trainingFiles.add("gs://" + bucketName + "/" + blob.getName());
656         }
657       }
658     }
659 
660     return trainingFiles;
661   }
662 
663   /**
664    * Creates an AutoML Translation dataset.
665    * 
666    * @param context the training job context
667    * @return dataset resource name
668    * @throws Exception if dataset creation fails
669    */
670   private String createAutoMLDataset(TrainingJobContext context) throws Exception {
671     try (TranslationServiceClient client = TranslationServiceClient.create()) {
672       LocationName parent = LocationName.of(context.projectId, context.location);
673 
674       Dataset dataset = Dataset.newBuilder()
675           .setDisplayName(context.modelName + "-dataset")
676           .setSourceLanguageCode(context.srcLang)
677           .setTargetLanguageCode(context.trgLang)
678           .build();
679 
680       CreateDatasetRequest request = CreateDatasetRequest.newBuilder()
681           .setParent(parent.toString())
682           .setDataset(dataset)
683           .build();
684 
685       OperationFuture<Dataset, ?> operationFuture = client.createDatasetAsync(request);
686       Dataset createdDataset = operationFuture.get();
687 
688       return createdDataset.getName();
689     }
690   }
691 
692   /**
693    * Imports training files into an AutoML dataset.
694    * 
695    * @param context       the training job context
696    * @param datasetId     the dataset resource name
697    * @param trainingFiles list of GCS URIs for training files
698    * @throws Exception if import fails
699    */
700   private void importDataToDataset(TrainingJobContext context, String datasetId,
701       List<String> trainingFiles) throws Exception {
702 
703     try (TranslationServiceClient client = TranslationServiceClient.create()) {
704       DatasetInputConfig.Builder inputConfigBuilder = DatasetInputConfig.newBuilder();
705 
706       for (String fileUri : trainingFiles) {
707         GcsInputSource gcsSource = GcsInputSource.newBuilder()
708             .setInputUri(fileUri)
709             .build();
710 
711         inputConfigBuilder.addInputFiles(
712             DatasetInputConfig.InputFile.newBuilder()
713                 .setGcsSource(gcsSource)
714                 .build());
715       }
716 
717       ImportDataRequest request = ImportDataRequest.newBuilder()
718           .setDataset(datasetId)
719           .setInputConfig(inputConfigBuilder.build())
720           .build();
721 
722       OperationFuture<Empty, ?> operationFuture = client.importDataAsync(request);
723       operationFuture.get();
724     }
725   }
726 
727   /**
728    * Trains an AutoML Translation model.
729    * 
730    * @param context   the training job context
731    * @param datasetId the dataset resource name
732    * @return model resource name
733    * @throws Exception if training fails
734    */
735   private String trainAutoMLModel(TrainingJobContext context, String datasetId) throws Exception {
736     try (TranslationServiceClient client = TranslationServiceClient.create()) {
737       LocationName parent = LocationName.of(context.projectId, context.location);
738 
739       Model model = Model.newBuilder()
740           .setDisplayName(context.modelName)
741           .setDataset(datasetId)
742           .build();
743 
744       CreateModelRequest request = CreateModelRequest.newBuilder()
745           .setParent(parent.toString())
746           .setModel(model)
747           .build();
748 
749       OperationFuture<Model, ?> operationFuture = client.createModelAsync(request);
750 
751       // Update progress periodically while training
752       while (!operationFuture.isDone()) {
753         Thread.sleep(60_000); // Check every minute
754 
755         if (isCancelled(context.trainingJobId)) {
756           operationFuture.cancel(true);
757           throw new InterruptedException("Training cancelled by user");
758         }
759 
760         // Increment progress (capped at 95%)
761         int currentProgress = getProgress(context.trainingJobId);
762         updateProgress(context.trainingJobId, Math.min(95, currentProgress + 2));
763       }
764 
765       Model createdModel = operationFuture.get();
766       return createdModel.getName();
767     }
768   }
769 
770   /**
771    * Checks if a training job has been cancelled.
772    * 
773    * @param trainingJobId the training job UUID
774    * @return true if cancelled, false otherwise
775    */
776   private boolean isCancelled(UUID trainingJobId) {
777     TrainingJobStatus status = getTrainingStatus(trainingJobId);
778     return TrainingJobStatus.CANCELLED.equals(status);
779   }
780 
781   /**
782    * Updates the status of a training job in Solr.
783    * 
784    * @param trainingJobId the training job UUID
785    * @param status        new status
786    * @param progress      progress percentage
787    * @param phase         current phase description
788    */
789   private void updateJobStatus(UUID trainingJobId, TrainingJobStatus status, int progress,
790       String phase) {
791     try {
792       SolrInputDocument doc = new SolrInputDocument();
793       doc.addField("trainingJobId", trainingJobId.toString());
794       doc.addField("status", Map.of("set", status.name()));
795       doc.addField("progress", Map.of("set", progress));
796       doc.addField("currentPhase", Map.of("set", phase));
797 
798       solrClient.add(Const.SOLR_CORE_ATH_AUTOML_JOBS, doc);
799       solrClient.commit(Const.SOLR_CORE_ATH_AUTOML_JOBS);
800 
801     } catch (Exception e) {
802       throw new RuntimeException("Error updating job status", e);
803     }
804   }
805 
806   /**
807    * Updates just the progress field in Solr.
808    * 
809    * @param trainingJobId the training job UUID
810    * @param progress      new progress percentage
811    */
812   private void updateProgress(UUID trainingJobId, int progress) {
813     try {
814       SolrInputDocument doc = new SolrInputDocument();
815       doc.addField("trainingJobId", trainingJobId.toString());
816       doc.addField("progress", Map.of("set", progress));
817 
818       solrClient.add(Const.SOLR_CORE_ATH_AUTOML_JOBS, doc);
819       solrClient.commit(Const.SOLR_CORE_ATH_AUTOML_JOBS);
820 
821     } catch (Exception e) {
822       // Log but don't fail - progress updates are non-critical
823       System.err.println("Error updating progress: " + e.getMessage());
824     }
825   }
826 
827   /**
828    * Updates the dataset ID in Solr.
829    * 
830    * @param trainingJobId the training job UUID
831    * @param datasetId     the dataset resource name
832    */
833   private void updateDatasetId(UUID trainingJobId, String datasetId) {
834     try {
835       SolrInputDocument doc = new SolrInputDocument();
836       doc.addField("trainingJobId", trainingJobId.toString());
837       doc.addField("datasetId", Map.of("set", datasetId));
838 
839       solrClient.add(Const.SOLR_CORE_ATH_AUTOML_JOBS, doc);
840       solrClient.commit(Const.SOLR_CORE_ATH_AUTOML_JOBS);
841 
842     } catch (Exception e) {
843       throw new RuntimeException("Error updating dataset ID", e);
844     }
845   }
846 
847   /**
848    * Marks a training job as completed successfully in Solr.
849    * 
850    * @param trainingJobId  the training job UUID
851    * @param modelId        the model resource name
852    * @param filesProcessed number of files processed
853    * @param totalSegments  total number of segments
854    */
855   private void completeJob(UUID trainingJobId, String modelId, int filesProcessed,
856       int totalSegments) {
857     try {
858       SolrInputDocument doc = new SolrInputDocument();
859       doc.addField("trainingJobId", trainingJobId.toString());
860       doc.addField("status", Map.of("set", TrainingJobStatus.COMPLETED.name()));
861       doc.addField("progress", Map.of("set", 100));
862       doc.addField("modelId", Map.of("set", modelId));
863       doc.addField("completedAt", Map.of("set", new Date()));
864       doc.addField("filesProcessed", Map.of("set", filesProcessed));
865       doc.addField("totalSegments", Map.of("set", (long) totalSegments));
866       doc.addField("trainSegments", Map.of("set", (long) (totalSegments * 0.8)));
867       doc.addField("validationSegments", Map.of("set", (long) (totalSegments * 0.1)));
868       doc.addField("testSegments", Map.of("set", (long) (totalSegments * 0.1)));
869 
870       solrClient.add(Const.SOLR_CORE_ATH_AUTOML_JOBS, doc);
871       solrClient.commit(Const.SOLR_CORE_ATH_AUTOML_JOBS);
872 
873     } catch (Exception e) {
874       throw new RuntimeException("Error completing job", e);
875     }
876   }
877 
878   /**
879    * Marks a training job as failed in Solr.
880    * 
881    * @param trainingJobId the training job UUID
882    * @param errorType     machine-readable error type
883    * @param errorMessage  human-readable error message
884    */
885   private void failJob(UUID trainingJobId, ErrorTypeEnum errorType, String errorMessage) {
886     try {
887       SolrInputDocument doc = new SolrInputDocument();
888       doc.addField("trainingJobId", trainingJobId.toString());
889       doc.addField("status", Map.of("set", TrainingJobStatus.FAILED.name()));
890       doc.addField("errorType", Map.of("set", errorType.name()));
891       doc.addField("errorMessage", Map.of("set", errorMessage));
892       doc.addField("failedAt", Map.of("set", new Date()));
893 
894       solrClient.add(Const.SOLR_CORE_ATH_AUTOML_JOBS, doc);
895       solrClient.commit(Const.SOLR_CORE_ATH_AUTOML_JOBS);
896 
897     } catch (Exception e) {
898       throw new RuntimeException("Error failing job", e);
899     }
900   }
901 
902   /**
903    * Retrieves a training job document from Solr.
904    * 
905    * @param trainingJobId the training job UUID
906    * @return Solr document, or null if not found
907    */
908   private SolrDocument getJobDocument(UUID trainingJobId) throws SolrServerException, IOException {
909     SolrQuery query = new SolrQuery("trainingJobId:" + trainingJobId.toString());
910     QueryResponse response = solrClient.query(Const.SOLR_CORE_ATH_AUTOML_JOBS, query);
911     SolrDocumentList docs = response.getResults();
912 
913     return docs.isEmpty() ? null : docs.get(0);
914   }
915 
916 }