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) TriggerPipelineTranslate(ctx context.Context, languages []string) (*ai_summarizer.PipelineTranslateResponse, 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) GetTranslationFromStorage(ctx context.Context, contractFolderID, noticePublicationID, language string) (ai_summarizer.StoredTranslation, 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.ToResponseWithLanguage(s.defaultLanguage) // Enrich with AI summary from MinIO storage (best-effort, non-blocking) s.enrichWithAISummary(ctx, tender, resp) return resp, nil } // enrichWithAISummary loads overall_summary from MinIO (PROC_//tender.json) // and attaches it to the response. The primary notice publication id is tried first, then related ids. // A previously stored ai_overall_summary on the tender (already mapped in ToResponse) is kept when MinIO // has no ready summary. On success the tender document is updated so list/search can return it without // another MinIO round-trip. func (s *tenderService) enrichWithAISummary(ctx context.Context, t *Tender, resp *TenderResponse) { if t == nil || resp == nil { return } if s.aiSummarizerStorage == nil || strings.TrimSpace(t.ContractFolderID) == "" { return } 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, }) } s.persistAIOverallSummary(enrichCtx, t, summary) } // persistAIOverallSummary stores the resolved summary on the tender for list/search responses. func (s *tenderService) persistAIOverallSummary(ctx context.Context, t *Tender, summary string) { summary = strings.TrimSpace(summary) if summary == "" || summary == strings.TrimSpace(t.AIOverallSummary) { return } t.AIOverallSummary = summary t.UpdatedAt = time.Now().Unix() if err := s.repository.Update(ctx, t); err != nil { s.logger.Warn("Failed to persist AI overall summary on tender", map[string]interface{}{ "tender_id": t.ID.Hex(), "error": err.Error(), }) } } // 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) { if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) { s.logger.Error("MinIO unavailable while fetching AI summary", map[string]interface{}{ "tender_id": t.ID, "contract_folder_id": t.ContractFolderID, "notice_id": noticeID, "error": err.Error(), }) } 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.ToResponseWithLanguage(s.defaultLanguage), 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") } 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) } empty := func() *TenderDocumentsResponse { return &TenderDocumentsResponse{ TenderID: id, Documents: []TenderDocumentResponse{}, } } if s.aiSummarizerStorage == nil { s.logger.Debug("AI summarizer storage not configured; returning empty tender documents list", map[string]interface{}{ "tender_id": id, }) return empty(), nil } if strings.TrimSpace(tender.ContractFolderID) == "" { return empty(), nil } storedDocuments, err := s.listDocumentsForTender(ctx, tender) if err != nil { s.logger.Warn("Listing tender documents failed; returning empty list", map[string]interface{}{ "tender_id": id, "contract_folder_id": tender.ContractFolderID, "notice_id": tender.NoticePublicationID, "error": err.Error(), }) storedDocuments = nil } 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, }) } } } page, 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(page.Items)) for _, tender := range page.Items { tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language))) } return &SearchResponse{ Tenders: tenderResponses, Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset), }, 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(page.Items)) for _, tender := range page.Items { tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language))) } return &SearchResponse{ Tenders: tenderResponses, Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset), }, 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(page.Items), }) tenderResponses := make([]TenderResponse, 0, len(page.Items)) for _, tender := range page.Items { tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language))) } return &SearchResponse{ Tenders: tenderResponses, Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset), }, 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 } } } page, 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(page.Items)) for _, tender := range page.Items { tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language))) } return &SearchResponse{ Tenders: tenderResponses, Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset), }, 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)}, } oldPage, 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 oldPage.Items { 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 loads from MinIO storage (async pipeline result). When no summary exists yet, // the pipeline is still running, or storage is unavailable, it returns 200-compatible // data with an empty overall_summary and a Source hint instead of an error. func (s *tenderService) GetAISummary(ctx context.Context, id string) (*AISummaryResponse, error) { if id == "" { return nil, fmt.Errorf("tender ID cannot be empty") } 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) } noticeID := strings.TrimSpace(t.NoticePublicationID) emptyResp := func(source string) *AISummaryResponse { return &AISummaryResponse{ NoticeID: noticeID, OverallSummary: "", Source: source, } } if noticeID == "" || strings.TrimSpace(t.ContractFolderID) == "" { return emptyResp("unavailable"), nil } if s.aiSummarizerStorage == nil { s.logger.Debug("AI summarizer storage not configured; returning empty AI summary", map[string]interface{}{ "tender_id": id, }) return emptyResp("unavailable"), nil } 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 emptyResp("pending"), nil } if errors.Is(err, ai_summarizer.ErrObjectNotFound) || errors.Is(err, ai_summarizer.ErrNoOverallSummary) { return emptyResp("unavailable"), nil } if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) { s.logger.Error("Cannot retrieve AI summary: MinIO connection unavailable", map[string]interface{}{ "tender_id": id, "contract_folder_id": t.ContractFolderID, "notice_id": noticeID, "error": err.Error(), }) return nil, fmt.Errorf("failed to get AI summary: MinIO connection unavailable: %w", err) } 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") } if t.NoticePublicationID == "" { return nil, fmt.Errorf("tender has no notice publication ID") } if strings.TrimSpace(t.ContractFolderID) == "" { return nil, fmt.Errorf("tender has no contract folder ID") } authorityName := "" if t.BuyerOrganization != nil { authorityName = t.BuyerOrganization.Name } reqBody := ai_summarizer.NewSummarizeRequest(ai_summarizer.SummarizeRequestInput{ ContractFolderID: t.ContractFolderID, NoticePublicationID: t.NoticePublicationID, DocumentURL: t.DocumentURI, Title: t.Title, Description: t.Description, Country: t.CountryCode, Currency: t.Currency, SubmissionDeadline: t.SubmissionDeadline, EstimatedValue: t.EstimatedValue, AuthorityName: authorityName, }) s.logger.Info("Triggering on-demand AI summarization", map[string]interface{}{ "tender_id": id, "contract_folder_id": t.ContractFolderID, "notice_publication_id": t.NoticePublicationID, }) // 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, "contract_folder_id": t.ContractFolderID, "notice_publication_id": t.NoticePublicationID, "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, "contract_folder_id": t.ContractFolderID, "notice_publication_id": t.NoticePublicationID, "summaries_count": len(summaries), }) noticePublicationID := resp.ResolvedNoticePublicationID() if noticePublicationID == "" { noticePublicationID = t.NoticePublicationID } contractFolderID := strings.TrimSpace(resp.ContractFolderID) if contractFolderID == "" { contractFolderID = t.ContractFolderID } return &AISummarizeResponse{ ContractFolderID: contractFolderID, NoticePublicationID: noticePublicationID, 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) { 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 { fields := map[string]interface{}{ "error": err.Error(), } if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) { s.logger.Error("Failed to list AI summaries: MinIO connection unavailable", fields) return nil, fmt.Errorf("failed to get all AI summaries: MinIO connection unavailable: %w", err) } s.logger.Error("Failed to get all AI summaries from MinIO", fields) 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 strings.TrimSpace(t.ContractFolderID) == "" { return nil, fmt.Errorf("tender has no contract folder ID") } if tr, ok := t.Translations[targetLanguage]; ok && strings.TrimSpace(tr.Title) != "" { return s.buildAITranslateResponse(t, targetLanguage, tr.Title, tr.Description), nil } title, description, err := s.resolveTranslation(ctx, t, targetLanguage) if err != nil { s.logger.Error("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) } s.storeTranslation(t, targetLanguage, title, description) if err := s.repository.Update(ctx, t); err != nil { return nil, fmt.Errorf("failed to persist translation: %w", err) } return s.buildAITranslateResponse(t, targetLanguage, title, description), nil } func (s *tenderService) buildAITranslateResponse(t *Tender, language, title, description string) *AITranslateResponse { return &AITranslateResponse{ ContractFolderID: t.ContractFolderID, NoticePublicationID: t.NoticePublicationID, Language: language, TranslatedTitle: title, TranslatedDescription: description, } } func (s *tenderService) resolveTranslation(ctx context.Context, t *Tender, language string) (string, string, error) { if s.aiSummarizerStorage != nil { stored, err := s.aiSummarizerStorage.GetTranslationFromStorage(ctx, t.ContractFolderID, t.NoticePublicationID, language) if err == nil { return stored.Title, stored.Description, nil } if !errors.Is(err, ai_summarizer.ErrTranslationNotReady) && !errors.Is(err, ai_summarizer.ErrObjectNotFound) && !errors.Is(err, ai_summarizer.ErrNoTranslation) { s.logger.Debug("Translation not available from storage", map[string]interface{}{ "tender_id": t.ID.Hex(), "language": language, "error": err.Error(), }) } } resp, err := s.aiSummarizerClient.FetchTranslationOnDemand(ctx, ai_summarizer.TranslateRequest{ ContractFolderID: t.ContractFolderID, NoticePublicationID: t.NoticePublicationID, Title: t.Title, Description: t.Description, Language: language, }) if err != nil { return "", "", err } return resp.TranslatedTitle, resp.TranslatedDescription, nil } func (s *tenderService) storeTranslation(t *Tender, language, title, description string) { if t.Translations == nil { t.Translations = make(map[string]TranslationEntry) } now := time.Now().Unix() t.Translations[language] = TranslationEntry{ Title: title, Description: description, Language: language, TranslatedAt: now, } if t.ProcessingMetadata.TranslatedData == nil { t.ProcessingMetadata.TranslatedData = make(map[string]string) } t.ProcessingMetadata.TranslatedData[language] = "done" t.ProcessingMetadata.TranslatedAt = now } func (s *tenderService) pickResponseLanguage(language *string) string { if language != nil { clean := strings.ToLower(strings.TrimSpace(*language)) if clean != "" { return clean } } return s.defaultLanguage }