package tender import ( "archive/zip" "context" "errors" "fmt" "io" "path" "path/filepath" "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) FetchTranslationOnDemand(ctx context.Context, reqBody ai_summarizer.TranslateRequest) (*ai_summarizer.TranslateResponse, error) } // AISummarizerStorage defines the interface for fetching summaries from MinIO storage. // Paths follow the AI team's procedure-scoped layout: artefacts for a notice live under // PROC_// in the shared bucket. type AISummarizerStorage interface { GetSummaryFromStorage(ctx context.Context, contractFolderID, noticePublicationID string) (string, error) ListDocuments(ctx context.Context, contractFolderID, noticePublicationID string) ([]ai_summarizer.StoredDocument, error) DownloadDocument(ctx context.Context, objectName string) (io.ReadCloser, error) } // TenderDocumentDownload contains an authorized document stream. type TenderDocumentDownload struct { Reader io.ReadCloser Filename string ContentType string Size int64 } // TenderService interface defines tender business logic operations type Service interface { // Tender operations GetByID(ctx context.Context, id string) (*TenderResponse, error) Update(ctx context.Context, req UpdateTenderRequest) (*TenderResponse, error) Delete(ctx context.Context, id string) error Search(ctx context.Context, req *SearchForm, pagination *response.Pagination) (*SearchResponse, error) Recommend(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) GetDocumentSummaries(ctx context.Context, id string) ([]DocumentSummary, error) GetDocuments(ctx context.Context, id string) (*TenderDocumentsResponse, error) DownloadDocuments(ctx context.Context, id string) (*TenderDocumentDownload, error) // AI summarization operations GetAISummary(ctx context.Context, id string) (*AISummaryResponse, error) TriggerAISummarize(ctx context.Context, id string) (*AISummarizeResponse, error) TriggerAITranslate(ctx context.Context, id, language string) (*AITranslateResponse, 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 } // tenderService implements TenderService interface type tenderService struct { repository TenderRepository companyService company.Service customerService customer.Service logger logger.Logger aiSummarizerClient AISummarizerClient aiSummarizerStorage AISummarizerStorage defaultLanguage string } const aiSummaryEnrichmentTimeout = 2 * time.Second var ( ErrTenderDocumentStorageUnavailable = errors.New("tender document storage is unavailable") ErrTenderDocumentNotFound = errors.New("tender document not found") ) // NewService creates a new tender service func NewService(repository TenderRepository, companyService company.Service, customerService customer.Service, logger logger.Logger, aiClient AISummarizerClient, aiStorage AISummarizerStorage, defaultLanguage string) Service { if strings.TrimSpace(defaultLanguage) == "" { defaultLanguage = "en" } return &tenderService{ repository: repository, companyService: companyService, customerService: customerService, logger: logger, aiSummarizerClient: aiClient, aiSummarizerStorage: aiStorage, defaultLanguage: strings.ToLower(defaultLanguage), } } // GetTenderByID retrieves a tender by ID func (s *tenderService) GetByID(ctx context.Context, id string) (*TenderResponse, error) { s.logger.Info("Retrieving tender by ID", map[string]interface{}{ "tender_id": id, }) tender, err := s.repository.GetByID(ctx, id) if err != nil { s.logger.Error("Failed to retrieve tender", map[string]interface{}{ "tender_id": id, "error": err.Error(), }) return nil, fmt.Errorf("failed to retrieve tender: %w", err) } resp := tender.ToResponse() if tr, ok := tender.Translations[s.defaultLanguage]; ok { resp.Title = tr.Title resp.Description = tr.Description resp.Language = s.defaultLanguage } // Enrich with AI summary from MinIO storage (best-effort, non-blocking) s.enrichWithAISummary(ctx, tender, 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. // // Because one tender record can span several TED notices (we merge by // contract_folder_id), we try the latest publication id first and fall back to // older RelatedNoticePublicationIDs if the AI pipeline only finished an // earlier notice in the procedure. func (s *tenderService) enrichWithAISummary(ctx context.Context, t *Tender, resp *TenderResponse) { if s.aiSummarizerStorage == nil || t == nil || t.ContractFolderID == "" { return } // Keep tender detail response fast even if AI storage is slow/unreachable. enrichCtx, cancel := context.WithTimeout(ctx, aiSummaryEnrichmentTimeout) defer cancel() summary, usedNotice, err := s.lookupSummaryForTender(enrichCtx, t) if err != nil { s.logger.Debug("AI summary not available for tender", map[string]interface{}{ "tender_id": resp.ID, "contract_folder_id": t.ContractFolderID, "notice_id": t.NoticePublicationID, "error": err.Error(), }) return } resp.OverallSummary = summary if usedNotice != t.NoticePublicationID { s.logger.Debug("AI summary resolved from related notice", map[string]interface{}{ "tender_id": resp.ID, "contract_folder_id": t.ContractFolderID, "primary_notice_id": t.NoticePublicationID, "used_notice_id": usedNotice, }) } } // lookupSummaryForTender returns the overall_summary for a tender by trying its // primary notice publication id first, then each related publication id in // reverse (newest-first) order. The notice id that actually served the summary // is returned alongside. func (s *tenderService) lookupSummaryForTender(ctx context.Context, t *Tender) (string, string, error) { if s.aiSummarizerStorage == nil { return "", "", fmt.Errorf("AI summarizer storage is not configured") } candidates := orderedNoticeCandidates(t) if len(candidates) == 0 { return "", "", ai_summarizer.ErrObjectNotFound } var lastErr error for _, noticeID := range candidates { summary, err := s.aiSummarizerStorage.GetSummaryFromStorage(ctx, t.ContractFolderID, noticeID) if err == nil { return summary, noticeID, nil } lastErr = err // Only "not found" / "not ready" justify trying the next candidate. // Anything else (network/storage error) is propagated immediately. if !errors.Is(err, ai_summarizer.ErrObjectNotFound) && !errors.Is(err, ai_summarizer.ErrSummaryNotReady) && !errors.Is(err, ai_summarizer.ErrNoOverallSummary) { return "", noticeID, err } } if lastErr == nil { lastErr = ai_summarizer.ErrObjectNotFound } return "", "", lastErr } // orderedNoticeCandidates returns the publication ids to try when resolving // AI-side artefacts for a (possibly merged) tender, newest-first and deduped. // The primary NoticePublicationID is tried first, then any older ids stored in // RelatedNoticePublicationIDs (which we append oldest-first; reverse so newest // wins). func orderedNoticeCandidates(t *Tender) []string { if t == nil { return nil } seen := make(map[string]struct{}, len(t.RelatedNoticePublicationIDs)+1) out := make([]string, 0, len(t.RelatedNoticePublicationIDs)+1) add := func(id string) { id = strings.TrimSpace(id) if id == "" { return } if _, ok := seen[id]; ok { return } seen[id] = struct{}{} out = append(out, id) } add(t.NoticePublicationID) for i := len(t.RelatedNoticePublicationIDs) - 1; i >= 0; i-- { add(t.RelatedNoticePublicationIDs[i]) } return out } // UpdateTender updates an existing tender func (s *tenderService) Update(ctx context.Context, req UpdateTenderRequest) (*TenderResponse, error) { s.logger.Info("Updating tender", map[string]interface{}{ "tender_id": req.ID, }) // Get existing tender tender, err := s.repository.GetByID(ctx, req.ID) if err != nil { s.logger.Error("Failed to find tender for update", map[string]interface{}{ "tender_id": req.ID, "error": err.Error(), }) return nil, fmt.Errorf("failed to find tender: %w", err) } // Update fields if provided translationSourceChanged := false if req.Title != nil { tender.Title = *req.Title translationSourceChanged = true } if req.Description != nil { tender.Description = *req.Description translationSourceChanged = true } if req.Status != nil { tender.Status = *req.Status } if req.EstimatedValue != nil { tender.EstimatedValue = *req.EstimatedValue } if req.Currency != nil { tender.Currency = *req.Currency } if req.TenderDeadline != nil { tender.TenderDeadline = *req.TenderDeadline } if translationSourceChanged { tender.Translations = nil tender.ProcessingMetadata.TranslatedData = nil tender.ProcessingMetadata.TranslatedAt = 0 } if err := s.repository.Update(ctx, tender); err != nil { s.logger.Error("Failed to update tender", map[string]interface{}{ "tender_id": req.ID, "error": err.Error(), }) return nil, fmt.Errorf("failed to update tender: %w", err) } s.logger.Info("Tender updated successfully", map[string]interface{}{ "tender_id": tender.ID, }) return tender.ToResponse(), nil } // Delete deletes a tender func (s *tenderService) Delete(ctx context.Context, id string) error { s.logger.Info("Deleting tender", map[string]interface{}{ "tender_id": id, }) if err := s.repository.Delete(ctx, id); err != nil { s.logger.Error("Failed to delete tender", map[string]interface{}{ "tender_id": id, "error": err.Error(), }) return fmt.Errorf("failed to delete tender: %w", err) } s.logger.Info("Tender deleted successfully", map[string]interface{}{ "tender_id": id, }) return nil } // GetDocumentSummaries retrieves document summaries for a specific tender func (s *tenderService) GetDocumentSummaries(ctx context.Context, id string) ([]DocumentSummary, error) { if id == "" { return nil, fmt.Errorf("tender ID cannot be empty") } // Get tender from repository tender, err := s.repository.GetByID(ctx, id) if err != nil { s.logger.Error("Failed to get tender for document summaries", map[string]interface{}{ "tender_id": id, "error": err.Error(), }) return nil, fmt.Errorf("failed to get tender: %w", err) } if tender == nil { return nil, fmt.Errorf("tender not found") } // Return document summaries (empty slice if none exist) if tender.DocumentSummaries == nil { return []DocumentSummary{}, nil } s.logger.Info("Retrieved document summaries for tender", map[string]interface{}{ "tender_id": id, "summaries_count": len(tender.DocumentSummaries), }) return tender.DocumentSummaries, nil } // GetDocuments retrieves scraped document metadata for a tender. func (s *tenderService) GetDocuments(ctx context.Context, id string) (*TenderDocumentsResponse, error) { if id == "" { return nil, fmt.Errorf("tender ID cannot be empty") } if s.aiSummarizerStorage == nil { return nil, ErrTenderDocumentStorageUnavailable } tender, err := s.repository.GetByID(ctx, id) if err != nil { s.logger.Error("Failed to get tender for documents", map[string]interface{}{ "tender_id": id, "error": err.Error(), }) return nil, fmt.Errorf("failed to get tender: %w", err) } if tender == nil { return nil, fmt.Errorf("tender not found") } if strings.TrimSpace(tender.ContractFolderID) == "" { return nil, fmt.Errorf("tender has no contract folder id") } if strings.TrimSpace(tender.NoticePublicationID) == "" { return nil, fmt.Errorf("tender notice publication ID is empty") } storedDocuments, err := s.listDocumentsForTender(ctx, tender) if err != nil { s.logger.Error("Failed to list tender documents from storage", map[string]interface{}{ "tender_id": id, "contract_folder_id": tender.ContractFolderID, "notice_id": tender.NoticePublicationID, "error": err.Error(), }) return nil, fmt.Errorf("failed to list tender documents: %w", err) } documents := make([]TenderDocumentResponse, 0, len(storedDocuments)) for _, doc := range storedDocuments { documents = append(documents, TenderDocumentResponse{ Filename: doc.DocumentName, Size: doc.Size, LastModified: doc.LastModified, DocumentType: doc.DocumentType, }) } s.logger.Info("Retrieved scraped documents for tender", map[string]interface{}{ "tender_id": id, "contract_folder_id": tender.ContractFolderID, "notice_id": tender.NoticePublicationID, "documents_count": len(documents), }) return &TenderDocumentsResponse{ TenderID: id, Documents: documents, }, nil } // listDocumentsForTender unions document listings across every notice tied to a // tender (primary + RelatedNoticePublicationIDs), deduplicating by object name. // This way, a merged tender (e.g. CN + Result for the same procedure) exposes // the documents from every notice that contributed to it. func (s *tenderService) listDocumentsForTender(ctx context.Context, t *Tender) ([]ai_summarizer.StoredDocument, error) { if s.aiSummarizerStorage == nil { return nil, ErrTenderDocumentStorageUnavailable } candidates := orderedNoticeCandidates(t) if len(candidates) == 0 { return nil, nil } seen := make(map[string]struct{}) var aggregated []ai_summarizer.StoredDocument var firstErr error for _, noticeID := range candidates { docs, err := s.aiSummarizerStorage.ListDocuments(ctx, t.ContractFolderID, noticeID) if err != nil { // Remember the first error, but keep trying other notices so a // missing folder for an older notice does not hide newer docs. if firstErr == nil { firstErr = err } continue } for _, doc := range docs { if _, ok := seen[doc.ObjectName]; ok { continue } seen[doc.ObjectName] = struct{}{} aggregated = append(aggregated, doc) } } if len(aggregated) == 0 && firstErr != nil { return nil, firstErr } return aggregated, nil } // DownloadDocuments returns a ZIP stream containing all documents for a tender. func (s *tenderService) DownloadDocuments(ctx context.Context, id string) (*TenderDocumentDownload, error) { if id == "" { return nil, fmt.Errorf("tender ID cannot be empty") } if s.aiSummarizerStorage == nil { return nil, ErrTenderDocumentStorageUnavailable } tender, err := s.repository.GetByID(ctx, id) if err != nil { s.logger.Error("Failed to get tender for document download", map[string]interface{}{ "tender_id": id, "error": err.Error(), }) return nil, fmt.Errorf("failed to get tender: %w", err) } if tender == nil { return nil, fmt.Errorf("tender not found") } if strings.TrimSpace(tender.ContractFolderID) == "" { return nil, ErrTenderDocumentNotFound } if strings.TrimSpace(tender.NoticePublicationID) == "" { return nil, ErrTenderDocumentNotFound } documents, err := s.listDocumentsForTender(ctx, tender) if err != nil { s.logger.Error("Failed to list tender documents for archive", map[string]interface{}{ "tender_id": id, "contract_folder_id": tender.ContractFolderID, "notice_id": tender.NoticePublicationID, "error": err.Error(), }) return nil, fmt.Errorf("failed to list tender documents: %w", err) } if len(documents) == 0 { return nil, ErrTenderDocumentNotFound } reader, writer := io.Pipe() go s.writeDocumentsArchive(ctx, writer, documents) filename := fmt.Sprintf("%s-documents.zip", tender.NoticePublicationID) s.logger.Info("Tender documents archive download prepared", map[string]interface{}{ "tender_id": id, "notice_id": tender.NoticePublicationID, "documents_count": len(documents), }) return &TenderDocumentDownload{ Reader: reader, Filename: filename, ContentType: "application/zip", }, nil } func (s *tenderService) writeDocumentsArchive(ctx context.Context, writer *io.PipeWriter, documents []ai_summarizer.StoredDocument) { zipWriter := zip.NewWriter(writer) usedNames := make(map[string]int, len(documents)) for _, doc := range documents { select { case <-ctx.Done(): zipWriter.Close() writer.CloseWithError(ctx.Err()) return default: } if err := s.addDocumentToArchive(ctx, zipWriter, usedNames, doc); err != nil { zipWriter.Close() writer.CloseWithError(err) return } } if err := zipWriter.Close(); err != nil { writer.CloseWithError(err) return } writer.Close() } func (s *tenderService) addDocumentToArchive(ctx context.Context, zipWriter *zip.Writer, usedNames map[string]int, doc ai_summarizer.StoredDocument) error { reader, err := s.aiSummarizerStorage.DownloadDocument(ctx, doc.ObjectName) if err != nil { return fmt.Errorf("failed to open document %s: %w", doc.DocumentName, err) } defer reader.Close() entry, err := zipWriter.Create(uniqueArchiveName(usedNames, doc.DocumentName)) if err != nil { return fmt.Errorf("failed to create archive entry for %s: %w", doc.DocumentName, err) } if _, err := io.Copy(entry, reader); err != nil { return fmt.Errorf("failed to write document %s to archive: %w", doc.DocumentName, err) } return nil } func uniqueArchiveName(usedNames map[string]int, filename string) string { cleanName := path.Base(filename) if cleanName == "." || cleanName == "/" || cleanName == "" { cleanName = "document" } count := usedNames[cleanName] usedNames[cleanName] = count + 1 if count == 0 { return cleanName } extension := filepath.Ext(cleanName) base := strings.TrimSuffix(cleanName, extension) return fmt.Sprintf("%s-%d%s", base, count+1, extension) } // ListTenders retrieves tenders with pagination and filtering func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) { s.logger.Info("Listing tenders", map[string]interface{}{ "limit": pagination.Limit, "offset": pagination.Offset, "company_id": form.CompanyID, "customer_id": form.CustomerID, "from": form.DeadlineFrom, "to": form.DeadlineTo, }) // If customer ID is provided, get customer keywords and add to search if form.CustomerID != nil && *form.CustomerID != "" { customerResp, err := s.customerService.GetByID(ctx, *form.CustomerID) if err == nil && customerResp != nil { // Only use keywords if status is ready if customerResp.KeywordsStatus == "ready" && len(customerResp.Keywords) > 0 { // Combine customer keywords with existing search query keywordsQuery := strings.Join(customerResp.Keywords, " ") if form.Search != nil && *form.Search != "" { combinedSearch := *form.Search + " " + keywordsQuery form.Search = &combinedSearch } else { form.Search = &keywordsQuery } s.logger.Info("Using customer keywords for search", map[string]interface{}{ "customer_id": *form.CustomerID, "keywords": customerResp.Keywords, }) } else { s.logger.Info("Customer keywords not ready, skipping keyword-based search", map[string]interface{}{ "customer_id": *form.CustomerID, "keywords_status": customerResp.KeywordsStatus, }) } } } tenders, total, err := s.repository.Search(ctx, form, pagination) if err != nil { s.logger.Error("Failed to list tenders", map[string]interface{}{ "error": err.Error(), }) return nil, fmt.Errorf("failed to list tenders: %w", err) } // If no company ID provided, return tenders without match percentage if form.CompanyID == nil || *form.CompanyID == "" { tenderResponses := make([]TenderResponse, 0, len(tenders)) for _, tender := range tenders { tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language))) } return &SearchResponse{ Tenders: tenderResponses, Metadata: pagination.Response(total), }, nil } // Get company CPV codes for match calculation comp, err := s.companyService.GetByID(ctx, *form.CompanyID) if err != nil { s.logger.Error("Failed to get company for match calculation", map[string]interface{}{ "company_id": *form.CompanyID, "error": err.Error(), }) // Continue without match percentage if company not found tenderResponses := make([]TenderResponse, 0, len(tenders)) for _, tender := range tenders { tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language))) } return &SearchResponse{ Tenders: tenderResponses, Metadata: pagination.Response(total), }, nil } var companyCPVCodes []string if comp.Tags != nil && comp.Tags.CPVCodes != nil { companyCPVCodes = comp.Tags.CPVCodes } s.logger.Info("Tenders sorted by match percentage", map[string]interface{}{ "company_id": *form.CompanyID, "company_cpv_codes": len(companyCPVCodes), "total_tenders": len(tenders), }) tenderResponses := make([]TenderResponse, 0, len(tenders)) for _, tender := range tenders { tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language))) } return &SearchResponse{ Tenders: tenderResponses, Metadata: pagination.Response(total), }, nil } // Recommend retrieves tenders with pagination and filtering func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) { s.logger.Info("Recommend tenders", map[string]interface{}{ "company_id": form.CompanyID, "customer_id": form.CustomerID, }) form.Status = []string{string(TenderStatusActive)} // Priority: Use customer keywords if available, otherwise use company CPV codes if form.CustomerID != nil && *form.CustomerID != "" { customerResp, err := s.customerService.GetByID(ctx, *form.CustomerID) if err == nil && customerResp != nil { // Only use keywords if status is ready if customerResp.KeywordsStatus == "ready" && len(customerResp.Keywords) > 0 { // Use customer keywords for recommendation keywordsQuery := strings.Join(customerResp.Keywords, " ") form.Search = &keywordsQuery s.logger.Info("Using customer keywords for recommendation", map[string]interface{}{ "customer_id": *form.CustomerID, "keywords": customerResp.Keywords, }) } else { s.logger.Info("Customer keywords not ready, falling back to company CPV codes", map[string]interface{}{ "customer_id": *form.CustomerID, "keywords_status": customerResp.KeywordsStatus, }) } } } // Fallback to company CPV codes if no customer keywords if form.Search == nil || *form.Search == "" { if form.CompanyID != nil && *form.CompanyID != "" { company, err := s.companyService.GetByID(ctx, *form.CompanyID) if err != nil { s.logger.Error("Failed to get company for CPV codes, skipping CPV-based filtering", map[string]interface{}{ "company_id": form.CompanyID, "error": err.Error(), }) // Continue without CPV-based filtering instead of failing } else if company.Tags != nil && len(company.Tags.CPVCodes) > 0 { form.Classifications = company.Tags.CPVCodes } } } tenders, total, err := s.repository.Search(ctx, form, pagination) if err != nil { s.logger.Error("Failed to list tenders", map[string]interface{}{ "error": err.Error(), }) return nil, fmt.Errorf("failed to list tenders: %w", err) } tenderResponses := make([]TenderResponse, 0, len(tenders)) for _, tender := range tenders { tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language))) } return &SearchResponse{ Tenders: tenderResponses, Metadata: pagination.Response(total), }, nil } // Delete deletes a tender // GetTenderStatistics retrieves tender statistics func (s *tenderService) GetTenderStatistics(ctx context.Context) (*TenderStatistics, error) { s.logger.Info("Retrieving tender statistics", map[string]interface{}{}) stats, err := s.repository.GetStatistics(ctx) if err != nil { s.logger.Error("Failed to retrieve tender statistics", map[string]interface{}{ "error": err.Error(), }) return nil, fmt.Errorf("failed to retrieve statistics: %w", err) } return stats, nil } // UpdateExpiredTenders updates status of expired tenders func (s *tenderService) UpdateExpiredTenders(ctx context.Context) error { s.logger.Info("Updating expired tenders", map[string]interface{}{}) // Get tenders that should be expired expiredTenders, err := s.repository.GetExpiredTenders(ctx, time.Now().Unix()) if err != nil { s.logger.Error("Failed to get expired tenders", map[string]interface{}{ "error": err.Error(), }) return fmt.Errorf("failed to get expired tenders: %w", err) } updatedCount := 0 for _, tender := range expiredTenders { if tender.Status != TenderStatusExpired { tender.UpdateStatus(TenderStatusExpired) if err := s.repository.Update(ctx, &tender); err != nil { s.logger.Error("Failed to update expired tender", map[string]interface{}{ "tender_id": tender.ID, "error": err.Error(), }) continue } updatedCount++ } } s.logger.Info("Expired tenders updated", map[string]interface{}{ "total_expired": len(expiredTenders), "updated": updatedCount, }) return nil } // CleanupOldData removes old data based on retention policy func (s *tenderService) CleanupOldData(ctx context.Context, olderThan time.Duration, pagination *response.Pagination) error { s.logger.Info("Cleaning up old data", map[string]interface{}{ "older_than": olderThan.String(), "limit": pagination.Limit, "offset": pagination.Offset, }) cutoffTime := time.Now().Add(-olderThan).Unix() // For now, we'll just log what would be cleaned up // In production, you might want to actually delete old data form := &SearchForm{ Status: []string{string(TenderStatusExpired), string(TenderStatusCancelled), string(TenderStatusAwarded)}, } oldTenders, _, err := s.repository.Search(ctx, form, pagination) if err != nil { s.logger.Error("Failed to find old tenders for cleanup", map[string]interface{}{ "error": err.Error(), }) return fmt.Errorf("failed to find old tenders: %w", err) } cleanupCount := 0 for _, tender := range oldTenders { if tender.CreatedAt < cutoffTime { cleanupCount++ // Here you would delete the tender if implementing actual cleanup // s.repository.Delete(ctx, tender.ID) } } s.logger.Info("Old data cleanup completed", map[string]interface{}{ "found_old_tenders": cleanupCount, "cutoff_time": cutoffTime, }) 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") } if t.ContractFolderID == "" { return nil, fmt.Errorf("tender has no contract folder id") } if s.aiSummarizerStorage == nil { return nil, fmt.Errorf("AI summarizer storage is not configured") } summary, usedNotice, err := s.lookupSummaryForTender(ctx, t) if err == nil { return &AISummaryResponse{ NoticeID: usedNotice, OverallSummary: summary, Source: "storage", }, nil } s.logger.Info("AI summary not available from storage", map[string]interface{}{ "tender_id": id, "contract_folder_id": t.ContractFolderID, "notice_id": noticeID, "error": err.Error(), }) 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) || errors.Is(err, ai_summarizer.ErrNoOverallSummary) { 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) } // 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 } // TriggerAITranslate triggers on-demand AI translation for a tender and stores // translated result per language in MongoDB for future reuse. func (s *tenderService) TriggerAITranslate(ctx context.Context, id, language string) (*AITranslateResponse, 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") } targetLanguage := strings.ToLower(strings.TrimSpace(language)) if targetLanguage == "" { targetLanguage = s.defaultLanguage } t, err := s.repository.GetByID(ctx, id) if err != nil { s.logger.Error("Failed to get tender for AI translation", 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") } if t.NoticePublicationID == "" { return nil, fmt.Errorf("tender has no notice publication ID") } if tr, ok := t.Translations[targetLanguage]; ok { return &AITranslateResponse{ NoticeID: t.NoticePublicationID, Language: targetLanguage, TranslatedTitle: tr.Title, TranslatedDescription: tr.Description, }, nil } resp, err := s.aiSummarizerClient.FetchTranslationOnDemand(ctx, ai_summarizer.TranslateRequest{ NoticeID: t.NoticePublicationID, Title: t.Title, Description: t.Description, Language: targetLanguage, }) if err != nil { s.logger.Error("On-demand AI translation failed", map[string]interface{}{ "tender_id": id, "notice_id": t.NoticePublicationID, "language": targetLanguage, "error": err.Error(), }) return nil, fmt.Errorf("AI translation request failed: %w", err) } if t.Translations == nil { t.Translations = make(map[string]TranslationEntry) } now := time.Now().Unix() t.Translations[targetLanguage] = TranslationEntry{ Title: resp.TranslatedTitle, Description: resp.TranslatedDescription, Language: targetLanguage, TranslatedAt: now, } if t.ProcessingMetadata.TranslatedData == nil { t.ProcessingMetadata.TranslatedData = make(map[string]string) } t.ProcessingMetadata.TranslatedData[targetLanguage] = "done" t.ProcessingMetadata.TranslatedAt = now if err := s.repository.Update(ctx, t); err != nil { return nil, fmt.Errorf("failed to persist translation: %w", err) } return &AITranslateResponse{ NoticeID: resp.NoticeID, Language: targetLanguage, TranslatedTitle: resp.TranslatedTitle, TranslatedDescription: resp.TranslatedDescription, }, nil } func (s *tenderService) pickResponseLanguage(language *string) string { if language != nil { clean := strings.ToLower(strings.TrimSpace(*language)) if clean != "" { return clean } } return s.defaultLanguage }