package ai_summarizer import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" "tm/pkg/logger" ) // StorageClient wraps a MinIO client configured to connect to the AI service's // MinIO bucket and retrieve tender.json files. type StorageClient struct { client *minio.Client config *Config logger logger.Logger } // TenderSummaryItem represents a single tender's summary retrieved from storage. type TenderSummaryItem struct { NoticeID string `json:"notice_id"` OverallSummary string `json:"overall_summary"` Done bool `json:"done"` } // NewStorageClient creates a new MinIO storage client for the AI service. // It establishes a connection to the external MinIO instance and verifies // that the configured bucket exists. func NewStorageClient(config *Config, log logger.Logger) (*StorageClient, error) { if config == nil { config = DefaultConfig() } if err := config.ValidateStorage(); err != nil { return nil, fmt.Errorf("ai_summarizer: invalid storage config: %w", err) } minioClient, err := minio.New(config.MinioEndpoint, &minio.Options{ Creds: credentials.NewStaticV4(config.MinioAccessKey, config.MinioSecretKey, ""), Secure: config.MinioUseSSL, Region: config.MinioRegion, }) if err != nil { return nil, fmt.Errorf("ai_summarizer: failed to create MinIO client: %w", err) } return &StorageClient{ client: minioClient, config: config, logger: log, }, nil } // GetSummaryFromStorage fetches the /tender.json file from the AI // service's MinIO bucket, parses it, and returns the overall_summary string. // // It returns: // - (summary, nil) on success // - ("", ErrObjectNotFound) if the tender.json does not exist (404) // - ("", ErrSummaryNotReady) if the file exists but summarize_status.done is false // - ("", ErrNoOverallSummary) if done is true but overall_summary is empty/null // - ("", error) for any other failure func (s *StorageClient) GetSummaryFromStorage(ctx context.Context, noticeID string) (string, error) { objectPath := fmt.Sprintf("%s/tender.json", noticeID) s.logger.Debug("Fetching tender.json from MinIO", map[string]interface{}{ "bucket": s.config.MinioBucket, "path": objectPath, }) // Attempt to get the object object, err := s.client.GetObject(ctx, s.config.MinioBucket, objectPath, minio.GetObjectOptions{}) if err != nil { // minio-go returns an error response that we can inspect var minioErr minio.ErrorResponse if errors.As(err, &minioErr) { switch minioErr.StatusCode { case http.StatusNotFound: return "", ErrObjectNotFound case http.StatusForbidden: return "", fmt.Errorf("ai_summarizer: access denied to bucket %s: %w", s.config.MinioBucket, err) default: return "", fmt.Errorf("ai_summarizer: MinIO error (code=%s, status=%d): %w", minioErr.Code, minioErr.StatusCode, err) } } return "", fmt.Errorf("ai_summarizer: failed to get object: %w", err) } defer object.Close() // Read the object content data, err := io.ReadAll(object) if err != nil { return "", fmt.Errorf("ai_summarizer: failed to read tender.json: %w", err) } // Parse the JSON var tenderJSON TenderJSON if err := json.Unmarshal(data, &tenderJSON); err != nil { return "", fmt.Errorf("ai_summarizer: failed to parse tender.json: %w", err) } // Check if summarization is complete if !tenderJSON.SummarizeStatus.Done { return "", ErrSummaryNotReady } // Check if overall_summary is available if tenderJSON.OverallSummary == nil || *tenderJSON.OverallSummary == "" { return "", ErrNoOverallSummary } s.logger.Info("Retrieved overall_summary from storage", map[string]interface{}{ "notice_id": noticeID, "summary_length": len(*tenderJSON.OverallSummary), }) return *tenderJSON.OverallSummary, nil } // IsSummaryReady is a convenience method that checks whether the tender.json // exists and the summarize_status.done flag is true, without returning the // actual summary text. func (s *StorageClient) IsSummaryReady(ctx context.Context, noticeID string) (bool, error) { objectPath := fmt.Sprintf("%s/tender.json", noticeID) object, err := s.client.GetObject(ctx, s.config.MinioBucket, objectPath, minio.GetObjectOptions{}) if err != nil { var minioErr minio.ErrorResponse if errors.As(err, &minioErr) && minioErr.StatusCode == http.StatusNotFound { return false, nil // not found means not ready, but not an error } return false, fmt.Errorf("ai_summarizer: failed to check summary readiness: %w", err) } defer object.Close() data, err := io.ReadAll(object) if err != nil { return false, fmt.Errorf("ai_summarizer: failed to read tender.json: %w", err) } var tenderJSON TenderJSON if err := json.Unmarshal(data, &tenderJSON); err != nil { return false, fmt.Errorf("ai_summarizer: failed to parse tender.json: %w", err) } return tenderJSON.SummarizeStatus.Done, nil } // GetAllSummaries lists all tender.json files in the MinIO bucket and returns // the overall_summary for each tender where summarize_status.done is true. // This is useful for batch-retrieving all summaries that the AI pipeline has // already processed. func (s *StorageClient) GetAllSummaries(ctx context.Context) ([]TenderSummaryItem, error) { s.logger.Debug("Listing all tender.json files from MinIO", map[string]interface{}{ "bucket": s.config.MinioBucket, }) // List all objects ending with /tender.json objectCh := s.client.ListObjects(ctx, s.config.MinioBucket, minio.ListObjectsOptions{ Prefix: "", Recursive: true, }) var results []TenderSummaryItem for object := range objectCh { if object.Err != nil { s.logger.Error("Error listing MinIO objects", map[string]interface{}{ "error": object.Err.Error(), }) continue } // Only process tender.json files (path pattern: /tender.json) if !strings.HasSuffix(object.Key, "/tender.json") { continue } // Extract notice_id from the path noticeID := strings.TrimSuffix(object.Key, "/tender.json") if noticeID == "" { continue } // Fetch and parse the tender.json summary, err := s.GetSummaryFromStorage(ctx, noticeID) if err != nil { // Log but skip tenders that aren't ready or have errors s.logger.Debug("Skipping tender summary", map[string]interface{}{ "notice_id": noticeID, "error": err.Error(), }) // Still include items that aren't done so the caller knows they exist if errors.Is(err, ErrSummaryNotReady) { results = append(results, TenderSummaryItem{ NoticeID: noticeID, Done: false, }) } continue } results = append(results, TenderSummaryItem{ NoticeID: noticeID, OverallSummary: summary, Done: true, }) } s.logger.Info("Retrieved all summaries from storage", map[string]interface{}{ "total_count": len(results), "completed_count": countDone(results), }) return results, nil } // countDone counts how many items have Done=true func countDone(items []TenderSummaryItem) int { count := 0 for _, item := range items { if item.Done { count++ } } return count }