Files
tm_back/internal/tender/service.go
T
Mazyar ca2a1b4425
continuous-integration/drone/push Build is passing
Enhance AI pipeline service with document metadata synchronization
- Updated the AI pipeline service to include a new `ScrapedDocumentMetadataSyncer` interface for persisting scraped document metadata onto tender records.
- Modified the `NewService` function to accept the new metadata syncer dependency.
- Implemented synchronization of scraped document metadata in the `ScrapeDocuments` and `Run` methods.
- Enhanced the tender service to enrich search filters based on scraped documents and added a new method for syncing scraped documents from storage.
- Updated the `SearchForm` to include `ContractFolderIDsWithDocuments` for better handling of scraped documents in queries.

This update improves the integration of scraped document handling within the AI pipeline, enhancing data consistency and operational efficiency in the tender management system.
2026-06-14 14:59:37 +03:30

1807 lines
60 KiB
Go

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"
)
// 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_<contractFolderID>/<noticePublicationID>/ 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
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, 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),
}
}
// 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 loads overall_summary from MinIO (PROC_<contractFolderID>/<noticePublicationID>/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)
}
// 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,
})
// 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,
})
}
}
}
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 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
}
}
}
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)
}
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)
}
if t == nil {
return nil, fmt.Errorf("tender not found")
}
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, ErrEmptyTenderID
}
if s.aiSummarizerClient == nil {
return nil, ErrAINotConfigured
}
// 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(),
})
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.NewSummarizeRequest(t.ContractFolderID, t.NoticePublicationID)
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
}
// 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) 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
}