package tender import ( "archive/zip" "context" "errors" "fmt" "io" "mime" "path" "path/filepath" "strings" "time" "tm/internal/company" "tm/internal/customer" "tm/pkg/ai_summarizer" "tm/pkg/logger" orm "tm/pkg/mongo" "tm/pkg/response" "golang.org/x/sync/errgroup" ) // AISummarizerClient defines the interface for on-demand AI operations. 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) FetchAnalyzeOnDemand(ctx context.Context, reqBody ai_summarizer.AnalyzeRequest) (*ai_summarizer.AnalyzeResponse, error) TriggerTranslateBatch(ctx context.Context, tenders []ai_summarizer.TenderRef, languages []string) (*ai_summarizer.BatchAcceptedResponse, error) TriggerSummarizeBatch(ctx context.Context, tenders []ai_summarizer.TenderRef) (*ai_summarizer.BatchAcceptedResponse, 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) GetDocumentSummariesFromStorage(ctx context.Context, contractFolderID, noticePublicationID string) ([]ai_summarizer.DocumentSummary, 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, language string) (*TenderResponse, error) GetByIDs(ctx context.Context, ids []string) (map[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) DownloadDocument(ctx context.Context, id, filename string) (*TenderDocumentDownload, error) // AI summarization operations GetAISummary(ctx context.Context, id string) (*AISummaryResponse, error) TriggerAISummarize(ctx context.Context, id string) (*AISummarizeResponse, error) TriggerAIAnalyze(ctx context.Context, id string) (*AIAnalyzeResponse, error) TriggerAITranslate(ctx context.Context, id, language string) (*AITranslateResponse, error) GetAllAISummaries(ctx context.Context) ([]ai_summarizer.TenderSummaryItem, error) // ListTendersWithScrapedDocuments lists tenders that have documents in MinIO. When syncMongoMetadata is true, // each tender is re-listed from MinIO and scraped document metadata is persisted to Mongo (slow). ListTendersWithScrapedDocuments(ctx context.Context, pagination *response.Pagination, syncMongoMetadata bool) (*TendersWithScrapedDocumentsResponse, error) // SyncScrapedDocumentsFromStorage lists documents in MinIO for a tender and persists scraped metadata to Mongo. SyncScrapedDocumentsFromStorage(ctx context.Context, contractFolderID, noticePublicationID string) 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 rejectedTenderLister CompanyRejectedTenderLister logger logger.Logger aiSummarizerClient AISummarizerClient aiSummarizerStorage AISummarizerStorage defaultLanguage string } const ( aiSummaryEnrichmentTimeout = 2 * time.Second aiTranslationEnrichmentTimeout = 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, rejectedTenderLister CompanyRejectedTenderLister, logger logger.Logger, aiClient AISummarizerClient, aiStorage AISummarizerStorage, defaultLanguage string, ) Service { if strings.TrimSpace(defaultLanguage) == "" { defaultLanguage = "en" } return &tenderService{ repository: repository, companyService: companyService, customerService: customerService, rejectedTenderLister: rejectedTenderLister, logger: logger, aiSummarizerClient: aiClient, aiSummarizerStorage: aiStorage, defaultLanguage: strings.ToLower(defaultLanguage), } } // GetByID retrieves a tender by ID. When language is empty, the service default (usually en) // is used to overlay title/description from MinIO translations when available. func (s *tenderService) GetByID(ctx context.Context, id string, language string) (*TenderResponse, error) { s.logger.Info("Retrieving tender by ID", map[string]interface{}{ "tender_id": id, "language": s.resolveDetailLanguage(language), }) 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) } return s.buildDetailResponse(ctx, tender, language), nil } // GetByIDs loads multiple tenders for list endpoints without MinIO or AI enrichment. func (s *tenderService) GetByIDs(ctx context.Context, ids []string) (map[string]*TenderResponse, error) { if len(ids) == 0 { return map[string]*TenderResponse{}, nil } tenders, err := s.repository.GetByIDs(ctx, ids) if err != nil { s.logger.Error("Failed to retrieve tenders by IDs", map[string]interface{}{ "count": len(ids), "error": err.Error(), }) return nil, fmt.Errorf("failed to retrieve tenders: %w", err) } out := make(map[string]*TenderResponse, len(tenders)) for i := range tenders { resp := tenders[i].ToResponseWithLanguage("") out[resp.ID] = resp } return out, nil } // buildDetailResponse maps a tender to API form and enriches translation (MinIO) and summary. func (s *tenderService) buildDetailResponse(ctx context.Context, tender *Tender, language string) *TenderResponse { resp := tender.ToResponseWithLanguage("") s.enrichWithTranslation(ctx, tender, resp, language) s.enrichWithAISummary(ctx, tender, resp) return resp } // enrichWithAISummary attaches the overall AI summary to a tender detail response. // It reads from MinIO when available; for scraped tenders without a summary it triggers // on-demand summarization via the AI service (same path as GetAISummary). func (s *tenderService) enrichWithAISummary(ctx context.Context, t *Tender, resp *TenderResponse) { if t == nil || resp == nil { return } result, err := s.resolveAISummary(ctx, t) if err != nil { s.logger.Debug("AI summary resolution failed for tender detail", map[string]interface{}{ "tender_id": resp.ID, "error": err.Error(), }) return } if result == nil || strings.TrimSpace(result.OverallSummary) == "" { return } resp.OverallSummary = result.OverallSummary } // enrichWithTranslation overlays title/description from MinIO when the pipeline has finished // for the requested language. Does not call the AI translate API on GET. func (s *tenderService) enrichWithTranslation(ctx context.Context, t *Tender, resp *TenderResponse, language string) { if t == nil || resp == nil { return } if s.aiSummarizerStorage == nil || strings.TrimSpace(t.ContractFolderID) == "" { return } targetLanguage := s.resolveDetailLanguage(language) enrichCtx, cancel := context.WithTimeout(ctx, aiTranslationEnrichmentTimeout) defer cancel() stored, usedNotice, err := s.lookupTranslationForTender(enrichCtx, t, targetLanguage) if err != nil { s.logger.Debug("Translation not available for tender detail", map[string]interface{}{ "tender_id": resp.ID, "contract_folder_id": t.ContractFolderID, "notice_id": t.NoticePublicationID, "language": targetLanguage, "error": err.Error(), }) return } if strings.TrimSpace(stored.Title) == "" { return } resp.Title = stored.Title resp.Description = stored.Description resp.Language = targetLanguage if usedNotice != t.NoticePublicationID { s.logger.Debug("Translation 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, "language": targetLanguage, }) } } // lookupTranslationForTender returns stored translation text, trying primary then related notice ids. func (s *tenderService) lookupTranslationForTender(ctx context.Context, t *Tender, language string) (ai_summarizer.StoredTranslation, string, error) { if s.aiSummarizerStorage == nil { return ai_summarizer.StoredTranslation{}, "", fmt.Errorf("AI summarizer storage is not configured") } candidates := orderedNoticeCandidates(t) if len(candidates) == 0 { return ai_summarizer.StoredTranslation{}, "", ai_summarizer.ErrObjectNotFound } var lastErr error for _, noticeID := range candidates { stored, err := s.aiSummarizerStorage.GetTranslationFromStorage(ctx, t.ContractFolderID, noticeID, language) if err == nil { return stored, noticeID, nil } lastErr = err if !errors.Is(err, ai_summarizer.ErrTranslationNotReady) && !errors.Is(err, ai_summarizer.ErrObjectNotFound) && !errors.Is(err, ai_summarizer.ErrNoTranslation) { if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) { s.logger.Error("MinIO unavailable while fetching translation", map[string]interface{}{ "tender_id": t.ID.Hex(), "contract_folder_id": t.ContractFolderID, "notice_id": noticeID, "language": language, "error": err.Error(), }) } return ai_summarizer.StoredTranslation{}, "", err } } if lastErr == nil { lastErr = ai_summarizer.ErrObjectNotFound } return ai_summarizer.StoredTranslation{}, "", lastErr } // resolveDetailLanguage returns the language code used for detail responses. func (s *tenderService) resolveDetailLanguage(language string) string { clean := strings.ToLower(strings.TrimSpace(language)) if clean == "" { return s.defaultLanguage } return clean } // 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 if req.Title != nil { tender.Title = *req.Title } if req.Description != nil { tender.Description = *req.Description } 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 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 s.buildDetailResponse(ctx, tender, ""), 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 per-document summaries from the AI service MinIO storage. func (s *tenderService) GetDocumentSummaries(ctx context.Context, id string) ([]DocumentSummary, 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 document summaries", 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 strings.TrimSpace(t.NoticePublicationID) == "" || strings.TrimSpace(t.ContractFolderID) == "" { return []DocumentSummary{}, nil } if s.aiSummarizerStorage == nil { return []DocumentSummary{}, nil } stored, err := s.aiSummarizerStorage.GetDocumentSummariesFromStorage(ctx, t.ContractFolderID, t.NoticePublicationID) if errors.Is(err, ai_summarizer.ErrSummaryNotReady) { return []DocumentSummary{}, nil } if errors.Is(err, ai_summarizer.ErrObjectNotFound) { return []DocumentSummary{}, nil } if err != nil { if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) { return nil, fmt.Errorf("failed to get document summaries: MinIO connection unavailable: %w", err) } s.logger.Error("Failed to get document summaries from storage", map[string]interface{}{ "tender_id": id, "error": err.Error(), }) return nil, fmt.Errorf("failed to get document summaries: %w", err) } summaries := mapAIDocumentSummaries(stored) s.logger.Info("Retrieved document summaries for tender", map[string]interface{}{ "tender_id": id, "summaries_count": len(summaries), "source": "storage", }) return summaries, nil } func mapAIDocumentSummaries(items []ai_summarizer.DocumentSummary) []DocumentSummary { if len(items) == 0 { return []DocumentSummary{} } out := make([]DocumentSummary, 0, len(items)) for _, item := range items { out = append(out, DocumentSummary{ DocumentName: item.DocumentName, Summary: item.Summary, SummaryLanguage: "en", DocumentType: item.DocumentType, SummaryModel: item.SummaryModel, Error: item.Error, }) } return out } // 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) } if tender == nil { return nil, fmt.Errorf("tender not found") } 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 } // DownloadDocument streams a single scraped document for a tender. func (s *tenderService) DownloadDocument(ctx context.Context, id, filename string) (*TenderDocumentDownload, error) { if id == "" { return nil, fmt.Errorf("tender ID cannot be empty") } if strings.TrimSpace(filename) == "" { return nil, fmt.Errorf("document filename 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, "filename": filename, "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 } documents, err := s.listDocumentsForTender(ctx, tender) if err != nil { s.logger.Error("Failed to list tender documents for download", map[string]interface{}{ "tender_id": id, "filename": filename, "contract_folder_id": tender.ContractFolderID, "error": err.Error(), }) return nil, fmt.Errorf("failed to list tender documents: %w", err) } doc, ok := findStoredDocumentByFilename(documents, filename) if !ok { return nil, ErrTenderDocumentNotFound } reader, err := s.aiSummarizerStorage.DownloadDocument(ctx, doc.ObjectName) if err != nil { s.logger.Error("Failed to open tender document from storage", map[string]interface{}{ "tender_id": id, "filename": filename, "object_name": doc.ObjectName, "error": err.Error(), }) return nil, fmt.Errorf("failed to download document: %w", err) } downloadName := path.Base(doc.DocumentName) contentType := mime.TypeByExtension(filepath.Ext(downloadName)) if contentType == "" { contentType = "application/octet-stream" } s.logger.Info("Tender document download prepared", map[string]interface{}{ "tender_id": id, "filename": downloadName, "size": doc.Size, }) return &TenderDocumentDownload{ Reader: reader, Filename: downloadName, ContentType: contentType, Size: doc.Size, }, nil } func findStoredDocumentByFilename(documents []ai_summarizer.StoredDocument, filename string) (ai_summarizer.StoredDocument, bool) { filename = strings.TrimSpace(filename) if filename == "" { return ai_summarizer.StoredDocument{}, false } wantBase := path.Base(filename) for _, doc := range documents { if doc.DocumentName == filename || path.Base(doc.DocumentName) == wantBase { return doc, true } } return ai_summarizer.StoredDocument{}, false } 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, }) // Profile keywords are not used for tender search. if form.DocumentsScraped { if err := s.enrichDocumentsScrapedFilter(ctx, form); err != nil { return nil, err } if len(form.ContractFolderIDsWithDocuments) == 0 { return s.emptySearchResponse(pagination), nil } } 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 AI-ranked tenders for the authenticated customer's company. func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) { companyIDs := normalizeRecommendationCompanyScope(form.CompanyIDs) companyID := "" if form.CompanyID != nil { companyID = strings.TrimSpace(*form.CompanyID) } if companyID == "" && len(companyIDs) == 0 { return nil, errors.New("company ID is required") } if pagination.Cursor != "" { return nil, fmt.Errorf("%w: cursor pagination is not supported for recommendations", response.ErrInvalidPagination) } s.logger.Info("Recommend tenders via AI service", map[string]interface{}{ "company_id": companyID, "company_ids": companyIDs, }) var ( recommendations []company.RecommendedTenderResponse err error ) if len(companyIDs) > 0 { recommendations, err = s.companyService.GetMergedAIRecommendations(ctx, companyIDs) } else { recommendations, err = s.companyService.GetAIRecommendations(ctx, companyID) } if err != nil { return nil, err } if len(recommendations) == 0 { return s.emptySearchResponse(pagination), nil } tenderByRef, err := s.resolveRecommendedTenders(ctx, recommendations) if err != nil { return nil, err } var ( excluded map[string]struct{} excludedByCompany map[string]map[string]struct{} ) if form.ExcludeRejectedTenders { if len(companyIDs) > 0 { excludedByCompany, err = s.buildRejectedTenderIDSetsForCompanies(ctx, companyIDs) } else { excluded, err = s.buildRejectedTenderIDSet(ctx, companyID) } if err != nil { s.logger.Error("Failed to build recommendation exclusions", map[string]interface{}{ "company_id": companyID, "company_ids": companyIDs, "error": err.Error(), }) return nil, fmt.Errorf("failed to build recommendation exclusions: %w", err) } } lang := s.pickResponseLanguage(form.Language) now := time.Now().Unix() type rankedRecommendedTender struct { tender Tender rank int analysis string tenderRef string } ordered := make([]rankedRecommendedTender, 0, len(recommendations)) seenTenderIDs := make(map[string]struct{}, len(recommendations)) for _, rec := range recommendations { tenderRef := strings.TrimSpace(rec.TenderID) tender, ok := tenderByRef[tenderRef] if !ok { continue } if len(companyIDs) > 0 { if isRecommendedTenderExcludedForAllCompanies(tender, tenderRef, excludedByCompany, companyIDs) { continue } } else if isRecommendedTenderExcluded(tender, tenderRef, excluded) { continue } if form.OnlyActiveDeadlines && !tender.IsRecommendable(now) { continue } if len(form.Status) > 0 && !statusAllowed(tender.Status, form.Status) { continue } if markResolvedRecommendedTenderSeen(seenTenderIDs, tender) { continue } ordered = append(ordered, rankedRecommendedTender{ tender: tender, rank: rec.Rank, analysis: rec.Analysis, tenderRef: tenderRef, }) } total := int64(len(ordered)) start := pagination.Offset if start > len(ordered) { start = len(ordered) } end := start + pagination.Limit if end > len(ordered) { end = len(ordered) } paged := make([]TenderResponse, 0, end-start) for _, item := range ordered[start:end] { paged = append(paged, s.buildRecommendedTenderResponse(ctx, item.tender, item.rank, item.analysis, item.tenderRef, lang)) } return &SearchResponse{ Tenders: paged, Metadata: pagination.ListMeta(total, "", end < len(ordered), start), }, nil } func (s *tenderService) buildRecommendedTenderResponse(ctx context.Context, tender Tender, rank int, analysis, tenderRef, language string) TenderResponse { resp := tender.ToResponseWithLanguage(language) s.enrichWithTranslation(ctx, &tender, resp, language) resp.Rank = rank resp.Analysis = analysis resp.ProcedureRef = tenderRef return *resp } func statusAllowed(status TenderStatus, allowed []string) bool { for _, s := range allowed { if string(status) == strings.TrimSpace(s) { return true } } return false } // 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 reads from MinIO when available; for scraped tenders without a summary it triggers // on-demand summarization. When no summary can be produced 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) } if t == nil { return nil, fmt.Errorf("tender not found") } return s.resolveAISummary(ctx, t) } // resolveAISummary returns the overall AI summary for a tender, triggering on-demand // summarization when documents are scraped but no summary exists in storage yet. func (s *tenderService) resolveAISummary(ctx context.Context, t *Tender) (*AISummaryResponse, error) { 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.aiSummarizerClient == nil { s.logger.Debug("AI summarizer not configured; returning empty AI summary", map[string]interface{}{ "tender_id": t.ID.Hex(), }) return emptyResp("unavailable"), nil } lookupCtx := ctx if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > aiSummaryEnrichmentTimeout { var cancel context.CancelFunc lookupCtx, cancel = context.WithTimeout(ctx, aiSummaryEnrichmentTimeout) defer cancel() } if s.aiSummarizerStorage != nil { summary, usedNotice, err := s.lookupSummaryForTender(lookupCtx, t) if err == nil { s.persistAIOverallSummary(ctx, t, summary) return &AISummaryResponse{ NoticeID: usedNotice, OverallSummary: summary, Source: "storage", }, nil } if !errors.Is(err, ai_summarizer.ErrSummaryNotReady) && !errors.Is(err, ai_summarizer.ErrObjectNotFound) && !errors.Is(err, ai_summarizer.ErrNoOverallSummary) { if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) { s.logger.Error("Cannot retrieve AI summary: MinIO connection unavailable", map[string]interface{}{ "tender_id": t.ID.Hex(), "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) } } if !s.tenderHasScrapedDocuments(t) { return emptyResp("unavailable"), nil } if s.aiSummarizerClient == nil { return emptyResp("unavailable"), nil } resp, err := s.fetchAISummaryOnDemand(ctx, t) if err != nil { s.logger.Error("On-demand AI summarization on tender read failed", map[string]interface{}{ "tender_id": t.ID.Hex(), "contract_folder_id": t.ContractFolderID, "notice_id": noticeID, "error": err.Error(), }) return emptyResp("pending"), nil } overall := derefString(resp.OverallSummary) usedNotice := resp.ResolvedNoticePublicationID() if usedNotice == "" { usedNotice = noticeID } if overall == "" && s.aiSummarizerStorage != nil { if summary, notice, lookupErr := s.lookupSummaryForTender(ctx, t); lookupErr == nil { overall = summary usedNotice = notice } } if overall != "" { s.persistAIOverallSummary(ctx, t, overall) return &AISummaryResponse{ NoticeID: usedNotice, OverallSummary: overall, Source: "generated", }, nil } return emptyResp("pending"), nil } func (s *tenderService) tenderHasScrapedDocuments(t *Tender) bool { if t == nil { return false } if t.ProcessingMetadata.DocumentsScraped { return true } return len(t.ScrapedDocuments) > 0 } func (s *tenderService) fetchAISummaryOnDemand(ctx context.Context, t *Tender) (*ai_summarizer.SummarizeResponse, error) { if s.aiSummarizerClient == nil { return nil, ErrAINotConfigured } if strings.TrimSpace(t.NoticePublicationID) == "" { return nil, ErrTenderMissingNoticeID } if strings.TrimSpace(t.ContractFolderID) == "" { return nil, ErrTenderMissingContractFolderID } reqBody := ai_summarizer.NewSummarizeRequest(t.ContractFolderID, t.NoticePublicationID) s.logger.Info("Triggering on-demand AI summarization", map[string]interface{}{ "tender_id": t.ID.Hex(), "contract_folder_id": t.ContractFolderID, "notice_publication_id": t.NoticePublicationID, }) resp, err := s.aiSummarizerClient.FetchSummaryOnDemand(ctx, reqBody) if err != nil { return nil, fmt.Errorf("AI summarization request failed: %w", err) } s.markTenderSummarized(ctx, t) return resp, nil } func (s *tenderService) markTenderSummarized(ctx context.Context, t *Tender) { now := time.Now().Unix() t.ProcessingMetadata.DocumentsSummarized = true t.ProcessingMetadata.DocumentsSummarizedAt = now t.UpdatedAt = now if err := s.repository.Update(ctx, t); err != nil { s.logger.Warn("Failed to update tender summarization status", map[string]interface{}{ "tender_id": t.ID.Hex(), "error": err.Error(), }) } } func derefString(value *string) string { if value == nil { return "" } return strings.TrimSpace(*value) } // 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, ErrEmptyTenderID } if s.aiSummarizerClient == nil { return nil, ErrAINotConfigured } 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(), }) if errors.Is(err, orm.ErrDocumentNotFound) { return nil, ErrTenderNotFound } if strings.Contains(err.Error(), "invalid id format") { return nil, ErrInvalidTenderID } return nil, fmt.Errorf("failed to get tender: %w", err) } if t == nil { return nil, ErrTenderNotFound } if !s.tenderHasScrapedDocuments(t) { return nil, ErrTenderDocumentsNotScraped } resp, err := s.fetchAISummaryOnDemand(ctx, t) 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, err } summaries := make([]AIDocumentSummary, 0, len(resp.Summaries)) for _, item := range resp.Summaries { summaries = append(summaries, AIDocumentSummary{ DocumentName: item.DocumentName, DocumentType: item.DocumentType, Summary: item.Summary, SummaryModel: item.SummaryModel, Error: item.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 } if overall := derefString(resp.OverallSummary); overall != "" { s.persistAIOverallSummary(ctx, t, overall) } return &AISummarizeResponse{ ContractFolderID: contractFolderID, NoticePublicationID: noticePublicationID, Summaries: summaries, OverallSummary: resp.OverallSummary, }, nil } // TriggerAIAnalyze triggers on-demand agentic analysis for a tender. func (s *tenderService) TriggerAIAnalyze(ctx context.Context, id string) (*AIAnalyzeResponse, error) { if id == "" { return nil, ErrEmptyTenderID } if s.aiSummarizerClient == nil { return nil, ErrAINotConfigured } t, err := s.repository.GetByID(ctx, id) if err != nil { s.logger.Error("Failed to get tender for on-demand analysis", map[string]interface{}{ "tender_id": id, "error": err.Error(), }) if errors.Is(err, orm.ErrDocumentNotFound) { return nil, ErrTenderNotFound } if strings.Contains(err.Error(), "invalid id format") { return nil, ErrInvalidTenderID } return nil, fmt.Errorf("failed to get tender: %w", err) } if t == nil { return nil, ErrTenderNotFound } if t.NoticePublicationID == "" { return nil, ErrTenderMissingNoticeID } if strings.TrimSpace(t.ContractFolderID) == "" { return nil, ErrTenderMissingContractFolderID } reqBody := ai_summarizer.NewAnalyzeRequest(t.ContractFolderID, t.NoticePublicationID) s.logger.Info("Triggering on-demand AI analysis", map[string]interface{}{ "tender_id": id, "contract_folder_id": t.ContractFolderID, "notice_publication_id": t.NoticePublicationID, }) resp, err := s.aiSummarizerClient.FetchAnalyzeOnDemand(ctx, reqBody) if err != nil { s.logger.Error("On-demand AI analysis 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 analysis request failed: %w", err) } documents := make([]AIAnalyzeDocument, 0, len(resp.Documents)) for _, doc := range resp.Documents { documents = append(documents, AIAnalyzeDocument{ Filename: doc.Filename, SizeBytes: doc.SizeBytes, TimesRead: doc.TimesRead, TimesQueried: doc.TimesQueried, Description: doc.Description, }) } noticePublicationID := strings.TrimSpace(resp.NoticePublicationID) if noticePublicationID == "" { noticePublicationID = t.NoticePublicationID } contractFolderID := strings.TrimSpace(resp.ContractFolderID) if contractFolderID == "" { contractFolderID = t.ContractFolderID } s.logger.Info("On-demand AI analysis completed", map[string]interface{}{ "tender_id": id, "contract_folder_id": contractFolderID, "notice_publication_id": noticePublicationID, "iterations": resp.Iterations, }) return &AIAnalyzeResponse{ ContractFolderID: contractFolderID, NoticePublicationID: noticePublicationID, Summary: resp.Summary, Documents: documents, ToolCallsLog: resp.ToolCallsLog, Iterations: resp.Iterations, Model: resp.Model, }, 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 } // ListTendersWithScrapedDocuments lists database tenders whose contract_folder_id has // documents in MinIO. By default uses counts from the initial MinIO procedure scan only (fast). // Set syncMongoMetadata=true to list every notice folder again and persist ScrapedDocuments to Mongo (slow). func (s *tenderService) ListTendersWithScrapedDocuments(ctx context.Context, pagination *response.Pagination, syncMongoMetadata bool) (*TendersWithScrapedDocumentsResponse, error) { s.logger.Info("Listing tenders with scraped documents from MinIO", map[string]interface{}{ "limit": pagination.Limit, "offset": pagination.Offset, "sync_mongo_metadata": syncMongoMetadata, }) procedures, err := s.listProceduresWithDocuments(ctx) if err != nil { fields := map[string]interface{}{"error": err.Error()} if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) { s.logger.Error("Failed to list scraped documents: MinIO unavailable", fields) return nil, fmt.Errorf("failed to list tenders with scraped documents: MinIO connection unavailable: %w", err) } s.logger.Error("Failed to list procedure documents from MinIO", fields) return nil, fmt.Errorf("failed to list tenders with scraped documents: %w", err) } folderIDs := make([]string, 0, len(procedures)) for _, proc := range procedures { folderIDs = append(folderIDs, proc.ContractFolderID) } tenderByFolder, err := s.repository.GetLatestByContractFolderIDs(ctx, folderIDs) if err != nil { return nil, fmt.Errorf("failed to load tenders by contract_folder_id: %w", err) } matched := make([]TenderScrapedDocumentsItem, 0, len(procedures)) for _, proc := range procedures { if proc.DocumentCount <= 0 { continue } tender, ok := tenderByFolder[strings.TrimSpace(proc.ContractFolderID)] if tender == nil || !ok { continue } item := tender.ToTenderScrapedDocumentsItem() item.DocumentCount = proc.DocumentCount item.DocumentsScrapedAt = proc.LatestModified if syncMongoMetadata { docs, listErr := s.listDocumentsForTender(ctx, tender) if listErr != nil { if errors.Is(listErr, ai_summarizer.ErrMinIOUnavailable) { return nil, fmt.Errorf("failed to list tenders with scraped documents: MinIO connection unavailable: %w", listErr) } s.logger.Debug("Skipping tender: could not list documents from MinIO", map[string]interface{}{ "tender_id": tender.ID.Hex(), "contract_folder_id": proc.ContractFolderID, "error": listErr.Error(), }) continue } if len(docs) == 0 { continue } s.persistScrapedDocuments(ctx, tender, docs) item.DocumentCount = len(docs) if tender.ProcessingMetadata.DocumentsScrapedAt != 0 { item.DocumentsScrapedAt = tender.ProcessingMetadata.DocumentsScrapedAt } } matched = append(matched, item) } total := int64(len(matched)) start := pagination.Offset if start > len(matched) { start = len(matched) } end := start + pagination.Limit if end > len(matched) { end = len(matched) } page := matched[start:end] hasMore := int64(end) < total s.logger.Info("Listed tenders with scraped documents", map[string]interface{}{ "count": len(page), "total_count": total, }) return &TendersWithScrapedDocumentsResponse{ Tenders: page, Metadata: pagination.ListMeta(total, "", hasMore, pagination.Offset), }, nil } // persistScrapedDocuments updates the tender when MinIO has scraped documents for its procedure. func (s *tenderService) persistScrapedDocuments(ctx context.Context, t *Tender, docs []ai_summarizer.StoredDocument) { if t == nil || len(docs) == 0 { return } bucket := "" if named, ok := s.aiSummarizerStorage.(interface{ BucketName() string }); ok { bucket = named.BucketName() } scraped := make([]ScrapedDocument, len(docs)) var latestModified int64 for i, doc := range docs { scraped[i] = ScrapedDocument{ Filename: doc.DocumentName, ObjectName: doc.ObjectName, BucketName: bucket, Size: doc.Size, ScrapedAt: doc.LastModified, } if doc.LastModified > latestModified { latestModified = doc.LastModified } } unchanged := t.ProcessingMetadata.DocumentsScraped && len(t.ScrapedDocuments) == len(scraped) if unchanged { return } t.ProcessingMetadata.DocumentsScraped = true if t.ProcessingMetadata.DocumentsScrapedAt == 0 { if latestModified > 0 { t.ProcessingMetadata.DocumentsScrapedAt = latestModified } else { t.ProcessingMetadata.DocumentsScrapedAt = time.Now().Unix() } } t.ScrapedDocuments = scraped t.UpdatedAt = time.Now().Unix() if err := s.repository.Update(ctx, t); err != nil { s.logger.Warn("Failed to persist scraped documents on tender", map[string]interface{}{ "tender_id": t.ID.Hex(), "contract_folder_id": t.ContractFolderID, "error": err.Error(), }) } } type procedureDocumentLister interface { ListProceduresWithDocuments(ctx context.Context) ([]ai_summarizer.ProcedureDocumentsSummary, error) } func (s *tenderService) procedureDocumentStorage() (procedureDocumentLister, error) { if s.aiSummarizerStorage == nil { return nil, fmt.Errorf("document storage is not configured") } storage, ok := s.aiSummarizerStorage.(procedureDocumentLister) if !ok { return nil, fmt.Errorf("document storage does not support procedure listing") } return storage, nil } func (s *tenderService) listProceduresWithDocuments(ctx context.Context) ([]ai_summarizer.ProcedureDocumentsSummary, error) { storage, err := s.procedureDocumentStorage() if err != nil { return nil, err } procedures, err := storage.ListProceduresWithDocuments(ctx) if err != nil { return nil, err } return procedures, nil } func (s *tenderService) enrichDocumentsScrapedFilter(ctx context.Context, form *SearchForm) error { procedures, err := s.listProceduresWithDocuments(ctx) if err != nil { s.logger.Error("Failed to resolve tenders with scraped documents from MinIO", map[string]interface{}{ "error": err.Error(), }) if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) { return fmt.Errorf("failed to resolve tenders with scraped documents: MinIO connection unavailable: %w", err) } return fmt.Errorf("failed to resolve tenders with scraped documents: %w", err) } folderIDs := make([]string, 0, len(procedures)) for _, proc := range procedures { if proc.DocumentCount <= 0 { continue } if id := strings.TrimSpace(proc.ContractFolderID); id != "" { folderIDs = append(folderIDs, id) } } form.ContractFolderIDsWithDocuments = folderIDs return nil } func (s *tenderService) resolveRecommendedTenders(ctx context.Context, recommendations []company.RecommendedTenderResponse) (map[string]Tender, error) { out := make(map[string]Tender) procRefs := make([]ProcedureReference, 0, len(recommendations)) mongoIDs := make([]string, 0) for _, rec := range recommendations { ref := strings.TrimSpace(rec.TenderID) if ref == "" { continue } if folder, notice, ok := ParseAIProcedureRef(ref); ok { procRefs = append(procRefs, ProcedureReference{ Ref: ref, ContractFolderID: folder, NoticePublicationID: notice, }) continue } if IsMongoObjectIDRef(ref) { mongoIDs = append(mongoIDs, ref) continue } s.logger.Warn("Skipping unrecognized recommended tender reference", map[string]interface{}{ "tender_ref": ref, }) } var ( byProcedure = make(map[string]Tender) byMongoID = make(map[string]Tender) ) group, groupCtx := errgroup.WithContext(ctx) if len(procRefs) > 0 { group.Go(func() error { byProcedureResult, err := s.repository.GetByProcedureReferences(groupCtx, procRefs) if err != nil { return fmt.Errorf("failed to load recommended tenders by procedure reference: %w", err) } for ref, tender := range byProcedureResult { byProcedure[ref] = tender } return nil }) } if len(mongoIDs) > 0 { group.Go(func() error { tenders, err := s.repository.GetByIDs(groupCtx, mongoIDs) if err != nil { s.logger.Error("Failed to load recommended tenders by Mongo ID", map[string]interface{}{ "error": err.Error(), }) return fmt.Errorf("failed to load recommended tenders: %w", err) } for i := range tenders { byMongoID[tenders[i].GetID()] = tenders[i] } return nil }) } if err := group.Wait(); err != nil { return nil, err } for ref, tender := range byProcedure { out[ref] = tender } for ref, tender := range byMongoID { out[ref] = tender } return out, nil } func (s *tenderService) emptySearchResponse(pagination *response.Pagination) *SearchResponse { return &SearchResponse{ Tenders: []TenderResponse{}, Metadata: pagination.ListMeta(0, "", false, pagination.Offset), } } // SyncScrapedDocumentsFromStorage lists documents in MinIO for a tender and persists scraped metadata to Mongo. func (s *tenderService) SyncScrapedDocumentsFromStorage(ctx context.Context, contractFolderID, noticePublicationID string) error { contractFolderID = strings.TrimSpace(contractFolderID) noticePublicationID = strings.TrimSpace(noticePublicationID) if contractFolderID == "" { return fmt.Errorf("contract folder ID is required") } var t *Tender var err error if noticePublicationID != "" { t, err = s.repository.GetByNoticePublicationID(ctx, noticePublicationID) } else { byFolder, lookupErr := s.repository.GetLatestByContractFolderIDs(ctx, []string{contractFolderID}) err = lookupErr if lookupErr == nil { t = byFolder[contractFolderID] } } if err != nil { s.logger.Error("Failed to load tender for scraped document sync", map[string]interface{}{ "contract_folder_id": contractFolderID, "notice_publication_id": noticePublicationID, "error": err.Error(), }) return fmt.Errorf("failed to load tender for scraped document sync: %w", err) } if t == nil { s.logger.Warn("Skipping scraped document metadata sync: tender not found", map[string]interface{}{ "contract_folder_id": contractFolderID, "notice_publication_id": noticePublicationID, }) return nil } docs, err := s.listDocumentsForTender(ctx, t) if err != nil { s.logger.Error("Failed to list scraped documents from storage", map[string]interface{}{ "tender_id": t.ID.Hex(), "contract_folder_id": contractFolderID, "notice_publication_id": noticePublicationID, "error": err.Error(), }) return fmt.Errorf("failed to list scraped documents from storage: %w", err) } if len(docs) == 0 { return nil } s.persistScrapedDocuments(ctx, t, docs) return nil } // TriggerAITranslate triggers on-demand AI translation for a tender. Results are // read from MinIO when available, otherwise fetched from the AI service (which persists to MinIO). 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") } title, description, fromAPI, err := s.resolveTranslation(ctx, t, targetLanguage, ai_summarizer.TranslationRequestSourceManualTrigger) 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) } if fromAPI { s.logger.Info("Manual AI translation request succeeded", map[string]interface{}{ "tender_id": id, "notice_id": t.NoticePublicationID, "language": targetLanguage, }) } 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, requestSource string) (string, string, bool, error) { if s.aiSummarizerStorage != nil { stored, _, err := s.lookupTranslationForTender(ctx, t, language) if err == nil && strings.TrimSpace(stored.Title) != "" { return stored.Title, stored.Description, false, nil } if err != nil && !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(), }) } } req := ai_summarizer.NewTranslateRequest( t.ContractFolderID, t.NoticePublicationID, language, t.Title, t.Description, ) req.RequestSource = requestSource resp, err := s.aiSummarizerClient.FetchTranslationOnDemand(ctx, req) if err != nil { return "", "", false, err } title := strings.TrimSpace(resp.TranslatedTitle) description := resp.TranslatedDescription if s.aiSummarizerStorage != nil { if stored, _, storageErr := s.lookupTranslationForTender(ctx, t, language); storageErr == nil && strings.TrimSpace(stored.Title) != "" { title = stored.Title description = stored.Description } } if title == "" { title = strings.TrimSpace(resp.TranslatedTitle) } return title, description, true, nil } func (s *tenderService) pickResponseLanguage(language *string) string { if language != nil { clean := strings.ToLower(strings.TrimSpace(*language)) if clean != "" { return clean } } return s.defaultLanguage }