Integrated AI summary
This commit is contained in:
+244
-10
@@ -2,15 +2,27 @@ package tender
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"tm/internal/company"
|
||||
"tm/internal/customer"
|
||||
"tm/pkg/ai_summarizer"
|
||||
"tm/pkg/logger"
|
||||
"tm/pkg/response"
|
||||
)
|
||||
|
||||
// AISummarizerClient defines the interface for on-demand AI summarization
|
||||
type AISummarizerClient interface {
|
||||
FetchSummaryOnDemand(ctx context.Context, reqBody ai_summarizer.SummarizeRequest) (*ai_summarizer.SummarizeResponse, error)
|
||||
}
|
||||
|
||||
// AISummarizerStorage defines the interface for fetching summaries from MinIO storage
|
||||
type AISummarizerStorage interface {
|
||||
GetSummaryFromStorage(ctx context.Context, noticeID string) (string, error)
|
||||
}
|
||||
|
||||
// TenderService interface defines tender business logic operations
|
||||
type Service interface {
|
||||
// Tender operations
|
||||
@@ -21,6 +33,11 @@ type Service interface {
|
||||
Recommend(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error)
|
||||
GetDocumentSummaries(ctx context.Context, id string) ([]DocumentSummary, error)
|
||||
|
||||
// AI summarization operations
|
||||
GetAISummary(ctx context.Context, id string) (*AISummaryResponse, error)
|
||||
TriggerAISummarize(ctx context.Context, id string) (*AISummarizeResponse, error)
|
||||
GetAllAISummaries(ctx context.Context) ([]ai_summarizer.TenderSummaryItem, error)
|
||||
|
||||
// Maintenance operations
|
||||
UpdateExpiredTenders(ctx context.Context) error
|
||||
CleanupOldData(ctx context.Context, olderThan time.Duration, pagination *response.Pagination) error
|
||||
@@ -28,19 +45,23 @@ type Service interface {
|
||||
|
||||
// tenderService implements TenderService interface
|
||||
type tenderService struct {
|
||||
repository TenderRepository
|
||||
companyService company.Service
|
||||
customerService customer.Service
|
||||
logger logger.Logger
|
||||
repository TenderRepository
|
||||
companyService company.Service
|
||||
customerService customer.Service
|
||||
logger logger.Logger
|
||||
aiSummarizerClient AISummarizerClient
|
||||
aiSummarizerStorage AISummarizerStorage
|
||||
}
|
||||
|
||||
// NewService creates a new tender service
|
||||
func NewService(repository TenderRepository, companyService company.Service, customerService customer.Service, logger logger.Logger) Service {
|
||||
func NewService(repository TenderRepository, companyService company.Service, customerService customer.Service, logger logger.Logger, aiClient AISummarizerClient, aiStorage AISummarizerStorage) Service {
|
||||
return &tenderService{
|
||||
repository: repository,
|
||||
companyService: companyService,
|
||||
customerService: customerService,
|
||||
logger: logger,
|
||||
repository: repository,
|
||||
companyService: companyService,
|
||||
customerService: customerService,
|
||||
logger: logger,
|
||||
aiSummarizerClient: aiClient,
|
||||
aiSummarizerStorage: aiStorage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +80,33 @@ func (s *tenderService) GetByID(ctx context.Context, id string) (*TenderResponse
|
||||
return nil, fmt.Errorf("failed to retrieve tender: %w", err)
|
||||
}
|
||||
|
||||
return tender.ToResponse(), nil
|
||||
resp := tender.ToResponse()
|
||||
|
||||
// Enrich with AI summary from MinIO storage (best-effort, non-blocking)
|
||||
s.enrichWithAISummary(ctx, resp)
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// enrichWithAISummary attempts to fetch the AI-generated overall summary from
|
||||
// MinIO storage and attaches it to the response. Failures are logged but do not
|
||||
// cause the request to fail — the field will simply be omitted.
|
||||
func (s *tenderService) enrichWithAISummary(ctx context.Context, resp *TenderResponse) {
|
||||
if s.aiSummarizerStorage == nil || resp.NoticePublicationID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := s.aiSummarizerStorage.GetSummaryFromStorage(ctx, resp.NoticePublicationID)
|
||||
if err != nil {
|
||||
s.logger.Debug("AI summary not available for tender", map[string]interface{}{
|
||||
"tender_id": resp.ID,
|
||||
"notice_id": resp.NoticePublicationID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resp.OverallSummary = summary
|
||||
}
|
||||
|
||||
// UpdateTender updates an existing tender
|
||||
@@ -428,3 +475,190 @@ func (s *tenderService) CleanupOldData(ctx context.Context, olderThan time.Durat
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAISummary retrieves the AI-generated summary for a tender.
|
||||
// It first tries to fetch from MinIO storage (async pipeline result).
|
||||
// If not found or not ready, it returns an appropriate error.
|
||||
func (s *tenderService) GetAISummary(ctx context.Context, id string) (*AISummaryResponse, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("tender ID cannot be empty")
|
||||
}
|
||||
|
||||
// Get tender to find the notice_publication_id
|
||||
t, err := s.repository.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get tender for AI summary", map[string]interface{}{
|
||||
"tender_id": id,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to get tender: %w", err)
|
||||
}
|
||||
|
||||
if t == nil {
|
||||
return nil, fmt.Errorf("tender not found")
|
||||
}
|
||||
|
||||
noticeID := t.NoticePublicationID
|
||||
if noticeID == "" {
|
||||
return nil, fmt.Errorf("tender has no notice publication ID")
|
||||
}
|
||||
|
||||
// Try to get summary from MinIO storage (async pipeline)
|
||||
if s.aiSummarizerStorage != nil {
|
||||
summary, err := s.aiSummarizerStorage.GetSummaryFromStorage(ctx, noticeID)
|
||||
if err == nil {
|
||||
return &AISummaryResponse{
|
||||
NoticeID: noticeID,
|
||||
OverallSummary: summary,
|
||||
Source: "storage",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Log but don't fail — the summary might just not be ready yet
|
||||
s.logger.Info("AI summary not available from storage", map[string]interface{}{
|
||||
"tender_id": id,
|
||||
"notice_id": noticeID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
|
||||
// Return specific error if summary not ready
|
||||
if errors.Is(err, ai_summarizer.ErrSummaryNotReady) {
|
||||
return nil, fmt.Errorf("AI summary is not ready yet, the pipeline is still processing")
|
||||
}
|
||||
if errors.Is(err, ai_summarizer.ErrObjectNotFound) {
|
||||
return nil, fmt.Errorf("AI summary not found — this tender has not been processed by the AI pipeline yet")
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to get AI summary: %w", err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("AI summarizer storage is not configured")
|
||||
}
|
||||
|
||||
// TriggerAISummarize triggers on-demand AI summarization for a tender.
|
||||
// It calls the external AI service HTTP API to generate summaries.
|
||||
func (s *tenderService) TriggerAISummarize(ctx context.Context, id string) (*AISummarizeResponse, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("tender ID cannot be empty")
|
||||
}
|
||||
|
||||
if s.aiSummarizerClient == nil {
|
||||
return nil, fmt.Errorf("AI summarizer service is not configured")
|
||||
}
|
||||
|
||||
// Get tender to build the request
|
||||
t, err := s.repository.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get tender for on-demand summarization", map[string]interface{}{
|
||||
"tender_id": id,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to get tender: %w", err)
|
||||
}
|
||||
|
||||
if t == nil {
|
||||
return nil, fmt.Errorf("tender not found")
|
||||
}
|
||||
|
||||
noticeID := t.NoticePublicationID
|
||||
if noticeID == "" {
|
||||
return nil, fmt.Errorf("tender has no notice publication ID")
|
||||
}
|
||||
|
||||
// Build the AI summarizer request
|
||||
reqBody := ai_summarizer.SummarizeRequest{
|
||||
NoticeID: noticeID,
|
||||
DocumentURL: t.DocumentURI,
|
||||
Title: t.Title,
|
||||
Description: t.Description,
|
||||
Country: t.CountryCode,
|
||||
SubmissionDeadline: time.UnixMilli(t.SubmissionDeadline).Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if t.EstimatedValue > 0 {
|
||||
ev := t.EstimatedValue
|
||||
reqBody.EstimatedValue = &ev
|
||||
}
|
||||
if t.Currency != "" {
|
||||
reqBody.Currency = t.Currency
|
||||
}
|
||||
if t.BuyerOrganization != nil {
|
||||
reqBody.AuthorityName = t.BuyerOrganization.Name
|
||||
}
|
||||
|
||||
s.logger.Info("Triggering on-demand AI summarization", map[string]interface{}{
|
||||
"tender_id": id,
|
||||
"notice_id": noticeID,
|
||||
})
|
||||
|
||||
// Call the AI service
|
||||
resp, err := s.aiSummarizerClient.FetchSummaryOnDemand(ctx, reqBody)
|
||||
if err != nil {
|
||||
s.logger.Error("On-demand AI summarization failed", map[string]interface{}{
|
||||
"tender_id": id,
|
||||
"notice_id": noticeID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("AI summarization request failed: %w", err)
|
||||
}
|
||||
|
||||
// Map the response to our response type
|
||||
summaries := make([]AIDocumentSummary, 0, len(resp.Summaries))
|
||||
for _, s := range resp.Summaries {
|
||||
summaries = append(summaries, AIDocumentSummary{
|
||||
DocumentName: s.DocumentName,
|
||||
DocumentType: s.DocumentType,
|
||||
Summary: s.Summary,
|
||||
SummaryModel: s.SummaryModel,
|
||||
Error: s.Error,
|
||||
})
|
||||
}
|
||||
|
||||
s.logger.Info("On-demand AI summarization completed", map[string]interface{}{
|
||||
"tender_id": id,
|
||||
"notice_id": noticeID,
|
||||
"summaries_count": len(summaries),
|
||||
})
|
||||
|
||||
return &AISummarizeResponse{
|
||||
NoticeID: resp.NoticeID,
|
||||
Summaries: summaries,
|
||||
OverallSummary: resp.OverallSummary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAllAISummaries retrieves all AI-generated summaries from the MinIO storage.
|
||||
// It lists all tender.json files in the bucket and returns summaries for tenders
|
||||
// where the AI pipeline has completed processing.
|
||||
func (s *tenderService) GetAllAISummaries(ctx context.Context) ([]ai_summarizer.TenderSummaryItem, error) {
|
||||
if s.aiSummarizerStorage == nil {
|
||||
return nil, fmt.Errorf("AI summarizer storage is not configured")
|
||||
}
|
||||
|
||||
s.logger.Info("Retrieving all AI summaries from storage", map[string]interface{}{})
|
||||
|
||||
// We need to access the underlying StorageClient to call GetAllSummaries.
|
||||
// Since aiSummarizerStorage is an interface, we type-assert to access GetAllSummaries.
|
||||
type allSummaries interface {
|
||||
GetAllSummaries(ctx context.Context) ([]ai_summarizer.TenderSummaryItem, error)
|
||||
}
|
||||
|
||||
storage, ok := s.aiSummarizerStorage.(allSummaries)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("AI summarizer storage does not support batch retrieval")
|
||||
}
|
||||
|
||||
results, err := storage.GetAllSummaries(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get all AI summaries", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to get all AI summaries: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Retrieved all AI summaries", map[string]interface{}{
|
||||
"total_count": len(results),
|
||||
})
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user