1419 lines
47 KiB
Go
1419 lines
47 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, language 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)
|
|
// 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)
|
|
|
|
// Maintenance operations
|
|
UpdateExpiredTenders(ctx context.Context) error
|
|
CleanupOldData(ctx context.Context, olderThan time.Duration, pagination *response.Pagination) error
|
|
}
|
|
|
|
// tenderService implements TenderService interface
|
|
type tenderService struct {
|
|
repository TenderRepository
|
|
companyService company.Service
|
|
customerService customer.Service
|
|
logger logger.Logger
|
|
aiSummarizerClient AISummarizerClient
|
|
aiSummarizerStorage AISummarizerStorage
|
|
defaultLanguage string
|
|
}
|
|
|
|
const (
|
|
aiSummaryEnrichmentTimeout = 2 * time.Second
|
|
aiTranslationEnrichmentTimeout = 2 * time.Second
|
|
)
|
|
|
|
var (
|
|
ErrTenderDocumentStorageUnavailable = errors.New("tender document storage is unavailable")
|
|
ErrTenderDocumentNotFound = errors.New("tender document not found")
|
|
)
|
|
|
|
// NewService creates a new tender service
|
|
func NewService(repository TenderRepository, companyService company.Service, customerService customer.Service, logger logger.Logger, aiClient AISummarizerClient, aiStorage AISummarizerStorage, defaultLanguage string) Service {
|
|
if strings.TrimSpace(defaultLanguage) == "" {
|
|
defaultLanguage = "en"
|
|
}
|
|
|
|
return &tenderService{
|
|
repository: repository,
|
|
companyService: companyService,
|
|
customerService: customerService,
|
|
logger: logger,
|
|
aiSummarizerClient: aiClient,
|
|
aiSummarizerStorage: aiStorage,
|
|
defaultLanguage: strings.ToLower(defaultLanguage),
|
|
}
|
|
}
|
|
|
|
// GetByID retrieves a tender by ID. When language is empty, the service default (usually en)
|
|
// is used to overlay title/description from MinIO translations when available.
|
|
func (s *tenderService) GetByID(ctx context.Context, id string, language string) (*TenderResponse, error) {
|
|
s.logger.Info("Retrieving tender by ID", map[string]interface{}{
|
|
"tender_id": id,
|
|
"language": s.resolveDetailLanguage(language),
|
|
})
|
|
|
|
tender, err := s.repository.GetByID(ctx, id)
|
|
if err != nil {
|
|
s.logger.Error("Failed to retrieve tender", map[string]interface{}{
|
|
"tender_id": id,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to retrieve tender: %w", err)
|
|
}
|
|
|
|
return s.buildDetailResponse(ctx, tender, language), nil
|
|
}
|
|
|
|
// buildDetailResponse maps a tender to API form and enriches translation (MinIO) and summary.
|
|
func (s *tenderService) buildDetailResponse(ctx context.Context, tender *Tender, language string) *TenderResponse {
|
|
resp := tender.ToResponseWithLanguage("")
|
|
s.enrichWithTranslation(ctx, tender, resp, language)
|
|
s.enrichWithAISummary(ctx, tender, resp)
|
|
return resp
|
|
}
|
|
|
|
// enrichWithAISummary loads overall_summary from MinIO (PROC_<contractFolderID>/<noticePublicationID>/tender.json)
|
|
// and attaches it to the response. The primary notice publication id is tried first, then related ids.
|
|
// A previously stored ai_overall_summary on the tender (already mapped in ToResponse) is kept when MinIO
|
|
// has no ready summary. On success the tender document is updated so list/search can return it without
|
|
// another MinIO round-trip.
|
|
func (s *tenderService) enrichWithAISummary(ctx context.Context, t *Tender, resp *TenderResponse) {
|
|
if t == nil || resp == nil {
|
|
return
|
|
}
|
|
if s.aiSummarizerStorage == nil || strings.TrimSpace(t.ContractFolderID) == "" {
|
|
return
|
|
}
|
|
|
|
enrichCtx, cancel := context.WithTimeout(ctx, aiSummaryEnrichmentTimeout)
|
|
defer cancel()
|
|
|
|
summary, usedNotice, err := s.lookupSummaryForTender(enrichCtx, t)
|
|
if err != nil {
|
|
s.logger.Debug("AI summary not available for tender", map[string]interface{}{
|
|
"tender_id": resp.ID,
|
|
"contract_folder_id": t.ContractFolderID,
|
|
"notice_id": t.NoticePublicationID,
|
|
"error": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
resp.OverallSummary = summary
|
|
if usedNotice != t.NoticePublicationID {
|
|
s.logger.Debug("AI summary resolved from related notice", map[string]interface{}{
|
|
"tender_id": resp.ID,
|
|
"contract_folder_id": t.ContractFolderID,
|
|
"primary_notice_id": t.NoticePublicationID,
|
|
"used_notice_id": usedNotice,
|
|
})
|
|
}
|
|
|
|
s.persistAIOverallSummary(enrichCtx, t, summary)
|
|
}
|
|
|
|
// enrichWithTranslation overlays title/description from MinIO when the pipeline has finished
|
|
// for the requested language. Does not call the AI translate API on GET.
|
|
func (s *tenderService) enrichWithTranslation(ctx context.Context, t *Tender, resp *TenderResponse, language string) {
|
|
if t == nil || resp == nil {
|
|
return
|
|
}
|
|
if s.aiSummarizerStorage == nil || strings.TrimSpace(t.ContractFolderID) == "" {
|
|
return
|
|
}
|
|
|
|
targetLanguage := s.resolveDetailLanguage(language)
|
|
enrichCtx, cancel := context.WithTimeout(ctx, aiTranslationEnrichmentTimeout)
|
|
defer cancel()
|
|
|
|
stored, usedNotice, err := s.lookupTranslationForTender(enrichCtx, t, targetLanguage)
|
|
if err != nil {
|
|
s.logger.Debug("Translation not available for tender detail", map[string]interface{}{
|
|
"tender_id": resp.ID,
|
|
"contract_folder_id": t.ContractFolderID,
|
|
"notice_id": t.NoticePublicationID,
|
|
"language": targetLanguage,
|
|
"error": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if strings.TrimSpace(stored.Title) == "" {
|
|
return
|
|
}
|
|
|
|
resp.Title = stored.Title
|
|
resp.Description = stored.Description
|
|
resp.Language = targetLanguage
|
|
if usedNotice != t.NoticePublicationID {
|
|
s.logger.Debug("Translation resolved from related notice", map[string]interface{}{
|
|
"tender_id": resp.ID,
|
|
"contract_folder_id": t.ContractFolderID,
|
|
"primary_notice_id": t.NoticePublicationID,
|
|
"used_notice_id": usedNotice,
|
|
"language": targetLanguage,
|
|
})
|
|
}
|
|
}
|
|
|
|
// lookupTranslationForTender returns stored translation text, trying primary then related notice ids.
|
|
func (s *tenderService) lookupTranslationForTender(ctx context.Context, t *Tender, language string) (ai_summarizer.StoredTranslation, string, error) {
|
|
if s.aiSummarizerStorage == nil {
|
|
return ai_summarizer.StoredTranslation{}, "", fmt.Errorf("AI summarizer storage is not configured")
|
|
}
|
|
candidates := orderedNoticeCandidates(t)
|
|
if len(candidates) == 0 {
|
|
return ai_summarizer.StoredTranslation{}, "", ai_summarizer.ErrObjectNotFound
|
|
}
|
|
|
|
var lastErr error
|
|
for _, noticeID := range candidates {
|
|
stored, err := s.aiSummarizerStorage.GetTranslationFromStorage(ctx, t.ContractFolderID, noticeID, language)
|
|
if err == nil {
|
|
return stored, noticeID, nil
|
|
}
|
|
lastErr = err
|
|
if !errors.Is(err, ai_summarizer.ErrTranslationNotReady) &&
|
|
!errors.Is(err, ai_summarizer.ErrObjectNotFound) &&
|
|
!errors.Is(err, ai_summarizer.ErrNoTranslation) {
|
|
if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) {
|
|
s.logger.Error("MinIO unavailable while fetching translation", map[string]interface{}{
|
|
"tender_id": t.ID.Hex(),
|
|
"contract_folder_id": t.ContractFolderID,
|
|
"notice_id": noticeID,
|
|
"language": language,
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
return ai_summarizer.StoredTranslation{}, "", err
|
|
}
|
|
}
|
|
if lastErr == nil {
|
|
lastErr = ai_summarizer.ErrObjectNotFound
|
|
}
|
|
return ai_summarizer.StoredTranslation{}, "", lastErr
|
|
}
|
|
|
|
// resolveDetailLanguage returns the language code used for detail responses.
|
|
func (s *tenderService) resolveDetailLanguage(language string) string {
|
|
clean := strings.ToLower(strings.TrimSpace(language))
|
|
if clean == "" {
|
|
return s.defaultLanguage
|
|
}
|
|
return clean
|
|
}
|
|
|
|
// persistAIOverallSummary stores the resolved summary on the tender for list/search responses.
|
|
func (s *tenderService) persistAIOverallSummary(ctx context.Context, t *Tender, summary string) {
|
|
summary = strings.TrimSpace(summary)
|
|
if summary == "" || summary == strings.TrimSpace(t.AIOverallSummary) {
|
|
return
|
|
}
|
|
|
|
t.AIOverallSummary = summary
|
|
t.UpdatedAt = time.Now().Unix()
|
|
if err := s.repository.Update(ctx, t); err != nil {
|
|
s.logger.Warn("Failed to persist AI overall summary on tender", map[string]interface{}{
|
|
"tender_id": t.ID.Hex(),
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// lookupSummaryForTender returns the overall_summary for a tender by trying its
|
|
// primary notice publication id first, then each related publication id in
|
|
// reverse (newest-first) order. The notice id that actually served the summary
|
|
// is returned alongside.
|
|
func (s *tenderService) lookupSummaryForTender(ctx context.Context, t *Tender) (string, string, error) {
|
|
if s.aiSummarizerStorage == nil {
|
|
return "", "", fmt.Errorf("AI summarizer storage is not configured")
|
|
}
|
|
candidates := orderedNoticeCandidates(t)
|
|
if len(candidates) == 0 {
|
|
return "", "", ai_summarizer.ErrObjectNotFound
|
|
}
|
|
|
|
var lastErr error
|
|
for _, noticeID := range candidates {
|
|
summary, err := s.aiSummarizerStorage.GetSummaryFromStorage(ctx, t.ContractFolderID, noticeID)
|
|
if err == nil {
|
|
return summary, noticeID, nil
|
|
}
|
|
lastErr = err
|
|
// Only "not found" / "not ready" justify trying the next candidate.
|
|
// Anything else (network/storage error) is propagated immediately.
|
|
if !errors.Is(err, ai_summarizer.ErrObjectNotFound) &&
|
|
!errors.Is(err, ai_summarizer.ErrSummaryNotReady) &&
|
|
!errors.Is(err, ai_summarizer.ErrNoOverallSummary) {
|
|
if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) {
|
|
s.logger.Error("MinIO unavailable while fetching AI summary", map[string]interface{}{
|
|
"tender_id": t.ID,
|
|
"contract_folder_id": t.ContractFolderID,
|
|
"notice_id": noticeID,
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
return "", noticeID, err
|
|
}
|
|
}
|
|
if lastErr == nil {
|
|
lastErr = ai_summarizer.ErrObjectNotFound
|
|
}
|
|
return "", "", lastErr
|
|
}
|
|
|
|
// orderedNoticeCandidates returns the publication ids to try when resolving
|
|
// AI-side artefacts for a (possibly merged) tender, newest-first and deduped.
|
|
// The primary NoticePublicationID is tried first, then any older ids stored in
|
|
// RelatedNoticePublicationIDs (which we append oldest-first; reverse so newest
|
|
// wins).
|
|
func orderedNoticeCandidates(t *Tender) []string {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
seen := make(map[string]struct{}, len(t.RelatedNoticePublicationIDs)+1)
|
|
out := make([]string, 0, len(t.RelatedNoticePublicationIDs)+1)
|
|
add := func(id string) {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
return
|
|
}
|
|
seen[id] = struct{}{}
|
|
out = append(out, id)
|
|
}
|
|
add(t.NoticePublicationID)
|
|
for i := len(t.RelatedNoticePublicationIDs) - 1; i >= 0; i-- {
|
|
add(t.RelatedNoticePublicationIDs[i])
|
|
}
|
|
return out
|
|
}
|
|
|
|
// UpdateTender updates an existing tender
|
|
func (s *tenderService) Update(ctx context.Context, req UpdateTenderRequest) (*TenderResponse, error) {
|
|
s.logger.Info("Updating tender", map[string]interface{}{
|
|
"tender_id": req.ID,
|
|
})
|
|
|
|
// Get existing tender
|
|
tender, err := s.repository.GetByID(ctx, req.ID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to find tender for update", map[string]interface{}{
|
|
"tender_id": req.ID,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to find tender: %w", err)
|
|
}
|
|
|
|
// Update fields if provided
|
|
if req.Title != nil {
|
|
tender.Title = *req.Title
|
|
}
|
|
if req.Description != nil {
|
|
tender.Description = *req.Description
|
|
}
|
|
if req.Status != nil {
|
|
tender.Status = *req.Status
|
|
}
|
|
if req.EstimatedValue != nil {
|
|
tender.EstimatedValue = *req.EstimatedValue
|
|
}
|
|
if req.Currency != nil {
|
|
tender.Currency = *req.Currency
|
|
}
|
|
if req.TenderDeadline != nil {
|
|
tender.TenderDeadline = *req.TenderDeadline
|
|
}
|
|
|
|
if err := s.repository.Update(ctx, tender); err != nil {
|
|
s.logger.Error("Failed to update tender", map[string]interface{}{
|
|
"tender_id": req.ID,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to update tender: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Tender updated successfully", map[string]interface{}{
|
|
"tender_id": tender.ID,
|
|
})
|
|
|
|
return s.buildDetailResponse(ctx, tender, ""), nil
|
|
}
|
|
|
|
// Delete deletes a tender
|
|
func (s *tenderService) Delete(ctx context.Context, id string) error {
|
|
s.logger.Info("Deleting tender", map[string]interface{}{
|
|
"tender_id": id,
|
|
})
|
|
|
|
if err := s.repository.Delete(ctx, id); err != nil {
|
|
s.logger.Error("Failed to delete tender", map[string]interface{}{
|
|
"tender_id": id,
|
|
"error": err.Error(),
|
|
})
|
|
return fmt.Errorf("failed to delete tender: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Tender deleted successfully", map[string]interface{}{
|
|
"tender_id": id,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetDocumentSummaries retrieves document summaries for a specific tender
|
|
func (s *tenderService) GetDocumentSummaries(ctx context.Context, id string) ([]DocumentSummary, error) {
|
|
if id == "" {
|
|
return nil, fmt.Errorf("tender ID cannot be empty")
|
|
}
|
|
|
|
// Get tender from repository
|
|
tender, err := s.repository.GetByID(ctx, id)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get tender for document summaries", map[string]interface{}{
|
|
"tender_id": id,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to get tender: %w", err)
|
|
}
|
|
|
|
if tender == nil {
|
|
return nil, fmt.Errorf("tender not found")
|
|
}
|
|
|
|
// Return document summaries (empty slice if none exist)
|
|
if tender.DocumentSummaries == nil {
|
|
return []DocumentSummary{}, nil
|
|
}
|
|
|
|
s.logger.Info("Retrieved document summaries for tender", map[string]interface{}{
|
|
"tender_id": id,
|
|
"summaries_count": len(tender.DocumentSummaries),
|
|
})
|
|
|
|
return tender.DocumentSummaries, nil
|
|
}
|
|
|
|
// GetDocuments retrieves scraped document metadata for a tender.
|
|
func (s *tenderService) GetDocuments(ctx context.Context, id string) (*TenderDocumentsResponse, error) {
|
|
if id == "" {
|
|
return nil, fmt.Errorf("tender ID cannot be empty")
|
|
}
|
|
|
|
tender, err := s.repository.GetByID(ctx, id)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get tender for documents", map[string]interface{}{
|
|
"tender_id": id,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to get tender: %w", err)
|
|
}
|
|
|
|
empty := func() *TenderDocumentsResponse {
|
|
return &TenderDocumentsResponse{
|
|
TenderID: id,
|
|
Documents: []TenderDocumentResponse{},
|
|
}
|
|
}
|
|
|
|
if s.aiSummarizerStorage == nil {
|
|
s.logger.Debug("AI summarizer storage not configured; returning empty tender documents list", map[string]interface{}{
|
|
"tender_id": id,
|
|
})
|
|
return empty(), nil
|
|
}
|
|
|
|
if strings.TrimSpace(tender.ContractFolderID) == "" {
|
|
return empty(), nil
|
|
}
|
|
|
|
storedDocuments, err := s.listDocumentsForTender(ctx, tender)
|
|
if err != nil {
|
|
s.logger.Warn("Listing tender documents failed; returning empty list", map[string]interface{}{
|
|
"tender_id": id,
|
|
"contract_folder_id": tender.ContractFolderID,
|
|
"notice_id": tender.NoticePublicationID,
|
|
"error": err.Error(),
|
|
})
|
|
storedDocuments = nil
|
|
}
|
|
|
|
documents := make([]TenderDocumentResponse, 0, len(storedDocuments))
|
|
for _, doc := range storedDocuments {
|
|
documents = append(documents, TenderDocumentResponse{
|
|
Filename: doc.DocumentName,
|
|
Size: doc.Size,
|
|
LastModified: doc.LastModified,
|
|
DocumentType: doc.DocumentType,
|
|
})
|
|
}
|
|
|
|
s.logger.Info("Retrieved scraped documents for tender", map[string]interface{}{
|
|
"tender_id": id,
|
|
"contract_folder_id": tender.ContractFolderID,
|
|
"notice_id": tender.NoticePublicationID,
|
|
"documents_count": len(documents),
|
|
})
|
|
|
|
return &TenderDocumentsResponse{
|
|
TenderID: id,
|
|
Documents: documents,
|
|
}, nil
|
|
}
|
|
|
|
// listDocumentsForTender unions document listings across every notice tied to a
|
|
// tender (primary + RelatedNoticePublicationIDs), deduplicating by object name.
|
|
// This way, a merged tender (e.g. CN + Result for the same procedure) exposes
|
|
// the documents from every notice that contributed to it.
|
|
func (s *tenderService) listDocumentsForTender(ctx context.Context, t *Tender) ([]ai_summarizer.StoredDocument, error) {
|
|
if s.aiSummarizerStorage == nil {
|
|
return nil, ErrTenderDocumentStorageUnavailable
|
|
}
|
|
|
|
candidates := orderedNoticeCandidates(t)
|
|
if len(candidates) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
seen := make(map[string]struct{})
|
|
var aggregated []ai_summarizer.StoredDocument
|
|
var firstErr error
|
|
for _, noticeID := range candidates {
|
|
docs, err := s.aiSummarizerStorage.ListDocuments(ctx, t.ContractFolderID, noticeID)
|
|
if err != nil {
|
|
// Remember the first error, but keep trying other notices so a
|
|
// missing folder for an older notice does not hide newer docs.
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
continue
|
|
}
|
|
for _, doc := range docs {
|
|
if _, ok := seen[doc.ObjectName]; ok {
|
|
continue
|
|
}
|
|
seen[doc.ObjectName] = struct{}{}
|
|
aggregated = append(aggregated, doc)
|
|
}
|
|
}
|
|
|
|
if len(aggregated) == 0 && firstErr != nil {
|
|
return nil, firstErr
|
|
}
|
|
return aggregated, nil
|
|
}
|
|
|
|
// DownloadDocuments returns a ZIP stream containing all documents for a tender.
|
|
func (s *tenderService) DownloadDocuments(ctx context.Context, id string) (*TenderDocumentDownload, error) {
|
|
if id == "" {
|
|
return nil, fmt.Errorf("tender ID cannot be empty")
|
|
}
|
|
if s.aiSummarizerStorage == nil {
|
|
return nil, ErrTenderDocumentStorageUnavailable
|
|
}
|
|
|
|
tender, err := s.repository.GetByID(ctx, id)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get tender for document download", map[string]interface{}{
|
|
"tender_id": id,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to get tender: %w", err)
|
|
}
|
|
if tender == nil {
|
|
return nil, fmt.Errorf("tender not found")
|
|
}
|
|
if strings.TrimSpace(tender.ContractFolderID) == "" {
|
|
return nil, ErrTenderDocumentNotFound
|
|
}
|
|
if strings.TrimSpace(tender.NoticePublicationID) == "" {
|
|
return nil, ErrTenderDocumentNotFound
|
|
}
|
|
|
|
documents, err := s.listDocumentsForTender(ctx, tender)
|
|
if err != nil {
|
|
s.logger.Error("Failed to list tender documents for archive", map[string]interface{}{
|
|
"tender_id": id,
|
|
"contract_folder_id": tender.ContractFolderID,
|
|
"notice_id": tender.NoticePublicationID,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to list tender documents: %w", err)
|
|
}
|
|
if len(documents) == 0 {
|
|
return nil, ErrTenderDocumentNotFound
|
|
}
|
|
|
|
reader, writer := io.Pipe()
|
|
go s.writeDocumentsArchive(ctx, writer, documents)
|
|
|
|
filename := fmt.Sprintf("%s-documents.zip", tender.NoticePublicationID)
|
|
s.logger.Info("Tender documents archive download prepared", map[string]interface{}{
|
|
"tender_id": id,
|
|
"notice_id": tender.NoticePublicationID,
|
|
"documents_count": len(documents),
|
|
})
|
|
|
|
return &TenderDocumentDownload{
|
|
Reader: reader,
|
|
Filename: filename,
|
|
ContentType: "application/zip",
|
|
}, nil
|
|
}
|
|
|
|
func (s *tenderService) writeDocumentsArchive(ctx context.Context, writer *io.PipeWriter, documents []ai_summarizer.StoredDocument) {
|
|
zipWriter := zip.NewWriter(writer)
|
|
usedNames := make(map[string]int, len(documents))
|
|
|
|
for _, doc := range documents {
|
|
select {
|
|
case <-ctx.Done():
|
|
zipWriter.Close()
|
|
writer.CloseWithError(ctx.Err())
|
|
return
|
|
default:
|
|
}
|
|
|
|
if err := s.addDocumentToArchive(ctx, zipWriter, usedNames, doc); err != nil {
|
|
zipWriter.Close()
|
|
writer.CloseWithError(err)
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := zipWriter.Close(); err != nil {
|
|
writer.CloseWithError(err)
|
|
return
|
|
}
|
|
writer.Close()
|
|
}
|
|
|
|
func (s *tenderService) addDocumentToArchive(ctx context.Context, zipWriter *zip.Writer, usedNames map[string]int, doc ai_summarizer.StoredDocument) error {
|
|
reader, err := s.aiSummarizerStorage.DownloadDocument(ctx, doc.ObjectName)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open document %s: %w", doc.DocumentName, err)
|
|
}
|
|
defer reader.Close()
|
|
|
|
entry, err := zipWriter.Create(uniqueArchiveName(usedNames, doc.DocumentName))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create archive entry for %s: %w", doc.DocumentName, err)
|
|
}
|
|
|
|
if _, err := io.Copy(entry, reader); err != nil {
|
|
return fmt.Errorf("failed to write document %s to archive: %w", doc.DocumentName, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func uniqueArchiveName(usedNames map[string]int, filename string) string {
|
|
cleanName := path.Base(filename)
|
|
if cleanName == "." || cleanName == "/" || cleanName == "" {
|
|
cleanName = "document"
|
|
}
|
|
|
|
count := usedNames[cleanName]
|
|
usedNames[cleanName] = count + 1
|
|
if count == 0 {
|
|
return cleanName
|
|
}
|
|
|
|
extension := filepath.Ext(cleanName)
|
|
base := strings.TrimSuffix(cleanName, extension)
|
|
return fmt.Sprintf("%s-%d%s", base, count+1, extension)
|
|
}
|
|
|
|
// ListTenders retrieves tenders with pagination and filtering
|
|
func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) {
|
|
s.logger.Info("Listing tenders", map[string]interface{}{
|
|
"limit": pagination.Limit,
|
|
"offset": pagination.Offset,
|
|
"company_id": form.CompanyID,
|
|
"customer_id": form.CustomerID,
|
|
"from": form.DeadlineFrom,
|
|
"to": form.DeadlineTo,
|
|
})
|
|
|
|
// If customer ID is provided, get customer keywords and add to search
|
|
if form.CustomerID != nil && *form.CustomerID != "" {
|
|
customerResp, err := s.customerService.GetByID(ctx, *form.CustomerID)
|
|
if err == nil && customerResp != nil {
|
|
// Only use keywords if status is ready
|
|
if customerResp.KeywordsStatus == "ready" && len(customerResp.Keywords) > 0 {
|
|
// Combine customer keywords with existing search query
|
|
keywordsQuery := strings.Join(customerResp.Keywords, " ")
|
|
if form.Search != nil && *form.Search != "" {
|
|
combinedSearch := *form.Search + " " + keywordsQuery
|
|
form.Search = &combinedSearch
|
|
} else {
|
|
form.Search = &keywordsQuery
|
|
}
|
|
s.logger.Info("Using customer keywords for search", map[string]interface{}{
|
|
"customer_id": *form.CustomerID,
|
|
"keywords": customerResp.Keywords,
|
|
})
|
|
} else {
|
|
s.logger.Info("Customer keywords not ready, skipping keyword-based search", map[string]interface{}{
|
|
"customer_id": *form.CustomerID,
|
|
"keywords_status": customerResp.KeywordsStatus,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
page, err := s.repository.Search(ctx, form, pagination)
|
|
if err != nil {
|
|
s.logger.Error("Failed to list tenders", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to list tenders: %w", err)
|
|
}
|
|
|
|
// If no company ID provided, return tenders without match percentage
|
|
if form.CompanyID == nil || *form.CompanyID == "" {
|
|
tenderResponses := make([]TenderResponse, 0, len(page.Items))
|
|
for _, tender := range page.Items {
|
|
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
|
|
}
|
|
|
|
return &SearchResponse{
|
|
Tenders: tenderResponses,
|
|
Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
|
|
}, nil
|
|
}
|
|
|
|
// Get company CPV codes for match calculation
|
|
comp, err := s.companyService.GetByID(ctx, *form.CompanyID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get company for match calculation", map[string]interface{}{
|
|
"company_id": *form.CompanyID,
|
|
"error": err.Error(),
|
|
})
|
|
// Continue without match percentage if company not found
|
|
tenderResponses := make([]TenderResponse, 0, len(page.Items))
|
|
for _, tender := range page.Items {
|
|
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
|
|
}
|
|
|
|
return &SearchResponse{
|
|
Tenders: tenderResponses,
|
|
Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
|
|
},
|
|
nil
|
|
}
|
|
|
|
var companyCPVCodes []string
|
|
if comp.Tags != nil && comp.Tags.CPVCodes != nil {
|
|
companyCPVCodes = comp.Tags.CPVCodes
|
|
}
|
|
|
|
s.logger.Info("Tenders sorted by match percentage", map[string]interface{}{
|
|
"company_id": *form.CompanyID,
|
|
"company_cpv_codes": len(companyCPVCodes),
|
|
"total_tenders": len(page.Items),
|
|
})
|
|
|
|
tenderResponses := make([]TenderResponse, 0, len(page.Items))
|
|
for _, tender := range page.Items {
|
|
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
|
|
}
|
|
|
|
return &SearchResponse{
|
|
Tenders: tenderResponses,
|
|
Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
|
|
},
|
|
nil
|
|
}
|
|
|
|
// Recommend retrieves tenders with pagination and filtering
|
|
func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) {
|
|
s.logger.Info("Recommend tenders", map[string]interface{}{
|
|
"company_id": form.CompanyID,
|
|
"customer_id": form.CustomerID,
|
|
})
|
|
|
|
form.Status = []string{string(TenderStatusActive)}
|
|
|
|
// Priority: Use customer keywords if available, otherwise use company CPV codes
|
|
if form.CustomerID != nil && *form.CustomerID != "" {
|
|
customerResp, err := s.customerService.GetByID(ctx, *form.CustomerID)
|
|
if err == nil && customerResp != nil {
|
|
// Only use keywords if status is ready
|
|
if customerResp.KeywordsStatus == "ready" && len(customerResp.Keywords) > 0 {
|
|
// Use customer keywords for recommendation
|
|
keywordsQuery := strings.Join(customerResp.Keywords, " ")
|
|
form.Search = &keywordsQuery
|
|
s.logger.Info("Using customer keywords for recommendation", map[string]interface{}{
|
|
"customer_id": *form.CustomerID,
|
|
"keywords": customerResp.Keywords,
|
|
})
|
|
} else {
|
|
s.logger.Info("Customer keywords not ready, falling back to company CPV codes", map[string]interface{}{
|
|
"customer_id": *form.CustomerID,
|
|
"keywords_status": customerResp.KeywordsStatus,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback to company CPV codes if no customer keywords
|
|
if form.Search == nil || *form.Search == "" {
|
|
if form.CompanyID != nil && *form.CompanyID != "" {
|
|
company, err := s.companyService.GetByID(ctx, *form.CompanyID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get company for CPV codes, skipping CPV-based filtering", map[string]interface{}{
|
|
"company_id": form.CompanyID,
|
|
"error": err.Error(),
|
|
})
|
|
// Continue without CPV-based filtering instead of failing
|
|
} else if company.Tags != nil && len(company.Tags.CPVCodes) > 0 {
|
|
form.Classifications = company.Tags.CPVCodes
|
|
}
|
|
}
|
|
}
|
|
|
|
page, err := s.repository.Search(ctx, form, pagination)
|
|
if err != nil {
|
|
s.logger.Error("Failed to list tenders", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to list tenders: %w", err)
|
|
}
|
|
|
|
tenderResponses := make([]TenderResponse, 0, len(page.Items))
|
|
for _, tender := range page.Items {
|
|
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
|
|
}
|
|
|
|
return &SearchResponse{
|
|
Tenders: tenderResponses,
|
|
Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
|
|
}, nil
|
|
}
|
|
|
|
// Delete deletes a tender
|
|
|
|
// GetTenderStatistics retrieves tender statistics
|
|
func (s *tenderService) GetTenderStatistics(ctx context.Context) (*TenderStatistics, error) {
|
|
s.logger.Info("Retrieving tender statistics", map[string]interface{}{})
|
|
|
|
stats, err := s.repository.GetStatistics(ctx)
|
|
if err != nil {
|
|
s.logger.Error("Failed to retrieve tender statistics", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to retrieve statistics: %w", err)
|
|
}
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
// UpdateExpiredTenders updates status of expired tenders
|
|
func (s *tenderService) UpdateExpiredTenders(ctx context.Context) error {
|
|
s.logger.Info("Updating expired tenders", map[string]interface{}{})
|
|
|
|
// Get tenders that should be expired
|
|
expiredTenders, err := s.repository.GetExpiredTenders(ctx, time.Now().Unix())
|
|
if err != nil {
|
|
s.logger.Error("Failed to get expired tenders", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
return fmt.Errorf("failed to get expired tenders: %w", err)
|
|
}
|
|
|
|
updatedCount := 0
|
|
for _, tender := range expiredTenders {
|
|
if tender.Status != TenderStatusExpired {
|
|
tender.UpdateStatus(TenderStatusExpired)
|
|
if err := s.repository.Update(ctx, &tender); err != nil {
|
|
s.logger.Error("Failed to update expired tender", map[string]interface{}{
|
|
"tender_id": tender.ID,
|
|
"error": err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
updatedCount++
|
|
}
|
|
}
|
|
|
|
s.logger.Info("Expired tenders updated", map[string]interface{}{
|
|
"total_expired": len(expiredTenders),
|
|
"updated": updatedCount,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// CleanupOldData removes old data based on retention policy
|
|
func (s *tenderService) CleanupOldData(ctx context.Context, olderThan time.Duration, pagination *response.Pagination) error {
|
|
s.logger.Info("Cleaning up old data", map[string]interface{}{
|
|
"older_than": olderThan.String(),
|
|
"limit": pagination.Limit,
|
|
"offset": pagination.Offset,
|
|
})
|
|
|
|
cutoffTime := time.Now().Add(-olderThan).Unix()
|
|
|
|
// For now, we'll just log what would be cleaned up
|
|
// In production, you might want to actually delete old data
|
|
form := &SearchForm{
|
|
Status: []string{string(TenderStatusExpired), string(TenderStatusCancelled), string(TenderStatusAwarded)},
|
|
}
|
|
|
|
oldPage, err := s.repository.Search(ctx, form, pagination)
|
|
if err != nil {
|
|
s.logger.Error("Failed to find old tenders for cleanup", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
return fmt.Errorf("failed to find old tenders: %w", err)
|
|
}
|
|
|
|
cleanupCount := 0
|
|
for _, tender := range oldPage.Items {
|
|
if tender.CreatedAt < cutoffTime {
|
|
cleanupCount++
|
|
// Here you would delete the tender if implementing actual cleanup
|
|
// s.repository.Delete(ctx, tender.ID)
|
|
}
|
|
}
|
|
|
|
s.logger.Info("Old data cleanup completed", map[string]interface{}{
|
|
"found_old_tenders": cleanupCount,
|
|
"cutoff_time": cutoffTime,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetAISummary retrieves the AI-generated summary for a tender.
|
|
// It loads from MinIO storage (async pipeline result). When no summary exists yet,
|
|
// the pipeline is still running, or storage is unavailable, it returns 200-compatible
|
|
// data with an empty overall_summary and a Source hint instead of an error.
|
|
func (s *tenderService) GetAISummary(ctx context.Context, id string) (*AISummaryResponse, error) {
|
|
if id == "" {
|
|
return nil, fmt.Errorf("tender ID cannot be empty")
|
|
}
|
|
|
|
t, err := s.repository.GetByID(ctx, id)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get tender for AI summary", map[string]interface{}{
|
|
"tender_id": id,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to get tender: %w", err)
|
|
}
|
|
|
|
noticeID := strings.TrimSpace(t.NoticePublicationID)
|
|
emptyResp := func(source string) *AISummaryResponse {
|
|
return &AISummaryResponse{
|
|
NoticeID: noticeID,
|
|
OverallSummary: "",
|
|
Source: source,
|
|
}
|
|
}
|
|
|
|
if noticeID == "" || strings.TrimSpace(t.ContractFolderID) == "" {
|
|
return emptyResp("unavailable"), nil
|
|
}
|
|
|
|
if s.aiSummarizerStorage == nil {
|
|
s.logger.Debug("AI summarizer storage not configured; returning empty AI summary", map[string]interface{}{
|
|
"tender_id": id,
|
|
})
|
|
return emptyResp("unavailable"), nil
|
|
}
|
|
|
|
summary, usedNotice, err := s.lookupSummaryForTender(ctx, t)
|
|
if err == nil {
|
|
return &AISummaryResponse{
|
|
NoticeID: usedNotice,
|
|
OverallSummary: summary,
|
|
Source: "storage",
|
|
}, nil
|
|
}
|
|
|
|
s.logger.Info("AI summary not available from storage", map[string]interface{}{
|
|
"tender_id": id,
|
|
"contract_folder_id": t.ContractFolderID,
|
|
"notice_id": noticeID,
|
|
"error": err.Error(),
|
|
})
|
|
|
|
if errors.Is(err, ai_summarizer.ErrSummaryNotReady) {
|
|
return emptyResp("pending"), nil
|
|
}
|
|
if errors.Is(err, ai_summarizer.ErrObjectNotFound) || errors.Is(err, ai_summarizer.ErrNoOverallSummary) {
|
|
return emptyResp("unavailable"), nil
|
|
}
|
|
if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) {
|
|
s.logger.Error("Cannot retrieve AI summary: MinIO connection unavailable", map[string]interface{}{
|
|
"tender_id": id,
|
|
"contract_folder_id": t.ContractFolderID,
|
|
"notice_id": noticeID,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to get AI summary: MinIO connection unavailable: %w", err)
|
|
}
|
|
return nil, fmt.Errorf("failed to get AI summary: %w", err)
|
|
}
|
|
|
|
// TriggerAISummarize triggers on-demand AI summarization for a tender.
|
|
// It calls the external AI service HTTP API to generate summaries.
|
|
func (s *tenderService) TriggerAISummarize(ctx context.Context, id string) (*AISummarizeResponse, error) {
|
|
if id == "" {
|
|
return nil, fmt.Errorf("tender ID cannot be empty")
|
|
}
|
|
|
|
if s.aiSummarizerClient == nil {
|
|
return nil, fmt.Errorf("AI summarizer service is not configured")
|
|
}
|
|
|
|
// Get tender to build the request
|
|
t, err := s.repository.GetByID(ctx, id)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get tender for on-demand summarization", map[string]interface{}{
|
|
"tender_id": id,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to get tender: %w", err)
|
|
}
|
|
|
|
if t == nil {
|
|
return nil, fmt.Errorf("tender not found")
|
|
}
|
|
|
|
if t.NoticePublicationID == "" {
|
|
return nil, fmt.Errorf("tender has no notice publication ID")
|
|
}
|
|
if strings.TrimSpace(t.ContractFolderID) == "" {
|
|
return nil, fmt.Errorf("tender has no contract folder ID")
|
|
}
|
|
|
|
authorityName := ""
|
|
if t.BuyerOrganization != nil {
|
|
authorityName = t.BuyerOrganization.Name
|
|
}
|
|
|
|
reqBody := ai_summarizer.NewSummarizeRequest(ai_summarizer.SummarizeRequestInput{
|
|
ContractFolderID: t.ContractFolderID,
|
|
NoticePublicationID: t.NoticePublicationID,
|
|
DocumentURL: t.DocumentURI,
|
|
Title: t.Title,
|
|
Description: t.Description,
|
|
Country: t.CountryCode,
|
|
Currency: t.Currency,
|
|
SubmissionDeadline: t.SubmissionDeadline,
|
|
EstimatedValue: t.EstimatedValue,
|
|
AuthorityName: authorityName,
|
|
})
|
|
|
|
s.logger.Info("Triggering on-demand AI summarization", map[string]interface{}{
|
|
"tender_id": id,
|
|
"contract_folder_id": t.ContractFolderID,
|
|
"notice_publication_id": t.NoticePublicationID,
|
|
})
|
|
|
|
// Call the AI service
|
|
resp, err := s.aiSummarizerClient.FetchSummaryOnDemand(ctx, reqBody)
|
|
if err != nil {
|
|
s.logger.Error("On-demand AI summarization failed", map[string]interface{}{
|
|
"tender_id": id,
|
|
"contract_folder_id": t.ContractFolderID,
|
|
"notice_publication_id": t.NoticePublicationID,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("AI summarization request failed: %w", err)
|
|
}
|
|
|
|
// Map the response to our response type
|
|
summaries := make([]AIDocumentSummary, 0, len(resp.Summaries))
|
|
for _, s := range resp.Summaries {
|
|
summaries = append(summaries, AIDocumentSummary{
|
|
DocumentName: s.DocumentName,
|
|
DocumentType: s.DocumentType,
|
|
Summary: s.Summary,
|
|
SummaryModel: s.SummaryModel,
|
|
Error: s.Error,
|
|
})
|
|
}
|
|
|
|
s.logger.Info("On-demand AI summarization completed", map[string]interface{}{
|
|
"tender_id": id,
|
|
"contract_folder_id": t.ContractFolderID,
|
|
"notice_publication_id": t.NoticePublicationID,
|
|
"summaries_count": len(summaries),
|
|
})
|
|
|
|
noticePublicationID := resp.ResolvedNoticePublicationID()
|
|
if noticePublicationID == "" {
|
|
noticePublicationID = t.NoticePublicationID
|
|
}
|
|
contractFolderID := strings.TrimSpace(resp.ContractFolderID)
|
|
if contractFolderID == "" {
|
|
contractFolderID = t.ContractFolderID
|
|
}
|
|
|
|
return &AISummarizeResponse{
|
|
ContractFolderID: contractFolderID,
|
|
NoticePublicationID: noticePublicationID,
|
|
Summaries: summaries,
|
|
OverallSummary: resp.OverallSummary,
|
|
}, nil
|
|
}
|
|
|
|
// GetAllAISummaries retrieves all AI-generated summaries from the MinIO storage.
|
|
// It lists all tender.json files in the bucket and returns summaries for tenders
|
|
// where the AI pipeline has completed processing.
|
|
func (s *tenderService) GetAllAISummaries(ctx context.Context) ([]ai_summarizer.TenderSummaryItem, error) {
|
|
|
|
s.logger.Info("Retrieving all AI summaries from storage", map[string]interface{}{})
|
|
|
|
// We need to access the underlying StorageClient to call GetAllSummaries.
|
|
// Since aiSummarizerStorage is an interface, we type-assert to access GetAllSummaries.
|
|
type allSummaries interface {
|
|
GetAllSummaries(ctx context.Context) ([]ai_summarizer.TenderSummaryItem, error)
|
|
}
|
|
|
|
storage, ok := s.aiSummarizerStorage.(allSummaries)
|
|
if !ok {
|
|
return nil, fmt.Errorf("AI summarizer storage does not support batch retrieval")
|
|
}
|
|
|
|
results, err := storage.GetAllSummaries(ctx)
|
|
if err != nil {
|
|
fields := map[string]interface{}{
|
|
"error": err.Error(),
|
|
}
|
|
if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) {
|
|
s.logger.Error("Failed to list AI summaries: MinIO connection unavailable", fields)
|
|
return nil, fmt.Errorf("failed to get all AI summaries: MinIO connection unavailable: %w", err)
|
|
}
|
|
s.logger.Error("Failed to get all AI summaries from MinIO", fields)
|
|
return nil, fmt.Errorf("failed to get all AI summaries: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Retrieved all AI summaries", map[string]interface{}{
|
|
"total_count": len(results),
|
|
})
|
|
|
|
return results, nil
|
|
}
|
|
|
|
// 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) {
|
|
if s.aiSummarizerStorage == nil {
|
|
return nil, fmt.Errorf("document storage is not configured")
|
|
}
|
|
|
|
type procedureLister interface {
|
|
ListProceduresWithDocuments(ctx context.Context) ([]ai_summarizer.ProcedureDocumentsSummary, error)
|
|
}
|
|
storage, ok := s.aiSummarizerStorage.(procedureLister)
|
|
if !ok {
|
|
return nil, fmt.Errorf("document storage does not support procedure listing")
|
|
}
|
|
|
|
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 := storage.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(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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, 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)
|
|
}
|
|
|
|
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) pickResponseLanguage(language *string) string {
|
|
if language != nil {
|
|
clean := strings.ToLower(strings.TrimSpace(*language))
|
|
if clean != "" {
|
|
return clean
|
|
}
|
|
}
|
|
return s.defaultLanguage
|
|
}
|