Files
tm_back/internal/tender/service.go
T
Mazyar 0fde5772e2 Add documents scraped filter functionality and related tests
- Introduced `documents_scraped_filter.go` to handle filtering tenders based on the presence of documents in MinIO, utilizing a caching mechanism to optimize performance.
- Implemented `enrichDocumentsScrapedFilter` and `cachedContractFolderIDsWithDocuments` methods in the `tenderService` for efficient retrieval of contract folder IDs with documents.
- Added unit tests in `documents_scraped_filter_test.go` to validate the new filtering logic and caching behavior, ensuring accurate results and error handling.
- Updated `SearchForm` documentation to clarify the caching aspect of `ContractFolderIDsWithDocuments`.
- Enhanced comments in the handler and repository to reflect the new filtering logic and its implications.

This update improves the tender search functionality by efficiently managing document presence checks, leading to better performance and user experience in the tender management system.
2026-07-12 21:54:21 +03:30

2034 lines
64 KiB
Go

package tender
import (
"archive/zip"
"context"
"errors"
"fmt"
"io"
"mime"
"path"
"path/filepath"
"strings"
"sync"
"time"
"tm/internal/company"
"tm/internal/customer"
"tm/pkg/ai_summarizer"
"tm/pkg/logger"
orm "tm/pkg/mongo"
"tm/pkg/redis"
"tm/pkg/response"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/singleflight"
)
// 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)
// ResolveMongoID maps a MongoDB id or AI procedure ref to the canonical tender MongoDB id.
ResolveMongoID(ctx context.Context, tenderRef string) (string, 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
redisClient redis.Client
pageCacheTTL time.Duration
defaultLanguage string
searchListCacheMu sync.Mutex
searchListCache map[string]searchListCacheEntry
searchListCacheGroup singleflight.Group
contractFoldersCacheMu sync.Mutex
contractFoldersCache contractFoldersWithDocsCacheEntry
contractFoldersCacheGroup singleflight.Group
}
const (
aiSummaryEnrichmentTimeout = 2 * time.Second
aiTranslationEnrichmentTimeout = 2 * time.Second
recommendTranslationEnrichmentTimeout = 5 * 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,
redisClient redis.Client,
recommendationPageCacheTTL time.Duration,
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,
redisClient: redisClient,
pageCacheTTL: recommendationPageCacheTTL,
defaultLanguage: strings.ToLower(defaultLanguage),
searchListCache: make(map[string]searchListCacheEntry),
}
}
// 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
}
// ResolveMongoID maps a MongoDB id or AI procedure ref (PROC_{folder}/{notice}) to the canonical tender id.
func (s *tenderService) ResolveMongoID(ctx context.Context, tenderRef string) (string, error) {
tenderRef = strings.TrimSpace(tenderRef)
if tenderRef == "" {
return "", fmt.Errorf("tender reference is required")
}
if IsMongoObjectIDRef(tenderRef) {
if _, err := s.repository.GetByID(ctx, tenderRef); err != nil {
return "", fmt.Errorf("tender not found")
}
return tenderRef, nil
}
if folder, notice, ok := ParseAIProcedureRef(tenderRef); ok {
t, err := s.repository.GetByProcedureReference(ctx, folder, notice)
if err != nil || t == nil {
return "", fmt.Errorf("tender not found")
}
return t.GetID(), nil
}
if _, err := s.repository.GetByID(ctx, tenderRef); err == nil {
return tenderRef, nil
}
return "", fmt.Errorf("tender not found")
}
// 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, nil)
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, cache *recommendationTranslationCache) {
s.enrichWithTranslationTimeout(ctx, t, resp, language, cache, aiTranslationEnrichmentTimeout)
}
func (s *tenderService) enrichWithTranslationTimeout(
ctx context.Context,
t *Tender,
resp *TenderResponse,
language string,
cache *recommendationTranslationCache,
enrichmentTimeout time.Duration,
) {
if t == nil || resp == nil {
return
}
if s.aiSummarizerStorage == nil || strings.TrimSpace(t.ContractFolderID) == "" {
return
}
targetLanguage := s.resolveDetailLanguage(language)
if cache != nil {
if stored, ok := cache.get(t.GetID(), targetLanguage); ok {
if strings.TrimSpace(stored.Title) != "" {
resp.Title = stored.Title
resp.Description = stored.Description
resp.Language = targetLanguage
}
return
}
}
enrichCtx, cancel := context.WithTimeout(ctx, enrichmentTimeout)
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
}
if cache != nil {
cache.set(t.GetID(), targetLanguage, stored)
}
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,
})
language := s.pickResponseLanguage(form.Language)
return s.cachedSearchList(ctx, form, pagination, language, func(loadCtx context.Context) (*SearchResponse, error) {
return s.searchUncached(loadCtx, form, pagination, language)
})
}
func (s *tenderService) searchUncached(ctx context.Context, form *SearchForm, pagination *response.Pagination, language string) (*SearchResponse, error) {
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(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(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(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.Debug("Recommend tenders request", map[string]interface{}{
"company_id": companyID,
"company_ids": companyIDs,
})
lang := s.pickResponseLanguage(form.Language)
if result, ok := s.recommendFromPageCache(ctx, companyID, companyIDs, form, pagination, lang); ok {
return result, nil
}
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
}
if len(companyIDs) == 0 && companyID != "" {
s.schedulePageCacheRefreshForLanguage(companyID, lang)
}
excluded, excludedByCompany, err := s.loadRecommendationExclusions(ctx, form, companyID, companyIDs)
if err != nil {
if form.ExcludeRejectedTenders {
s.logger.Error("Failed to build recommendation exclusions", map[string]interface{}{
"company_id": companyID,
"company_ids": companyIDs,
"error": err.Error(),
})
}
return nil, err
}
now := time.Now().Unix()
pageResult, err := s.buildRecommendationPage(
ctx,
recommendations,
form,
companyIDs,
excluded,
excludedByCompany,
now,
pagination.Offset,
pagination.Limit,
)
if err != nil {
return nil, err
}
paged := s.buildRecommendedTenderListResponses(pageResult.items, lang)
paged = s.enrichRecommendedTenderResponsesParallel(ctx, paged, lang)
return &SearchResponse{
Tenders: paged,
Metadata: pagination.ListMeta(pageResult.total, "", int(pagination.Offset)+len(paged) < int(pageResult.total), pagination.Offset),
}, nil
}
func (s *tenderService) buildRecommendedTenderResponse(ctx context.Context, tender Tender, rank int, analysis, tenderRef, language string, cache *recommendationTranslationCache) TenderResponse {
resp := tender.ToResponseWithLanguage(language)
s.enrichWithTranslation(ctx, &tender, resp, language, cache)
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) 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
}