Files
tm_back/internal/tender/service.go
T
2026-05-16 12:56:10 +03:30

1146 lines
37 KiB
Go

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