Files
tm_back/pkg/ai_summarizer/storage.go
T

328 lines
9.6 KiB
Go

package ai_summarizer
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"path/filepath"
"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 <noticeID>/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
}
// ListDocuments lists document files stored under <noticeID>/.
func (s *StorageClient) ListDocuments(ctx context.Context, noticeID string) ([]StoredDocument, error) {
noticeID = strings.TrimSpace(noticeID)
if noticeID == "" {
return nil, fmt.Errorf("ai_summarizer: noticeID is required")
}
prefix := fmt.Sprintf("%s/", noticeID)
objectCh := s.client.ListObjects(ctx, s.config.MinioBucket, minio.ListObjectsOptions{
Prefix: prefix,
Recursive: true,
})
documents := make([]StoredDocument, 0)
for object := range objectCh {
if object.Err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to list documents for notice %s: %w", noticeID, object.Err)
}
if !isTenderDocumentObject(noticeID, object.Key) {
continue
}
documentName := filepath.Base(object.Key)
documents = append(documents, StoredDocument{
ObjectName: object.Key,
DocumentName: documentName,
Size: object.Size,
LastModified: object.LastModified.Unix(),
DocumentType: documentType(documentName),
})
}
s.logger.Info("Listed tender documents from storage", map[string]interface{}{
"notice_id": noticeID,
"documents_count": len(documents),
})
return documents, nil
}
func isTenderDocumentObject(noticeID, objectName string) bool {
if objectName == "" || strings.HasSuffix(objectName, "/") {
return false
}
relativePath := strings.TrimPrefix(objectName, fmt.Sprintf("%s/", noticeID))
if relativePath == "" {
return false
}
filename := strings.ToLower(filepath.Base(relativePath))
switch filename {
case "tender.json", "ai_cache.db":
return false
}
switch strings.ToLower(filepath.Ext(filename)) {
case ".json", ".db", ".sqlite", ".sqlite3":
return false
default:
return true
}
}
// DownloadDocument opens a document object from the configured MinIO bucket.
func (s *StorageClient) DownloadDocument(ctx context.Context, objectName string) (io.ReadCloser, error) {
objectName = strings.TrimSpace(objectName)
if objectName == "" {
return nil, fmt.Errorf("ai_summarizer: objectName is required")
}
object, err := s.client.GetObject(ctx, s.config.MinioBucket, objectName, minio.GetObjectOptions{})
if err != nil {
var minioErr minio.ErrorResponse
if errors.As(err, &minioErr) && minioErr.StatusCode == http.StatusNotFound {
return nil, ErrObjectNotFound
}
return nil, fmt.Errorf("ai_summarizer: failed to get document object: %w", err)
}
return object, 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: <notice_id>/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
}
func documentType(filename string) string {
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(filename)), ".")
if extension == "" {
return "unknown"
}
return extension
}