784c3d6563
continuous-integration/drone/push Build is passing
- Removed the `RecommendationPageCacheLanguages` configuration from `AISummarizerConfig` to streamline cache management. - Updated the `companyService` and `tenderService` to utilize the new `InvalidateRecommendedTendersPageCache` method for cache invalidation, enhancing clarity and efficiency. - Refactored the `invalidateRecommendedTendersPageCache` method to eliminate unnecessary context parameters, simplifying the function signature. - Improved the handling of page cache refresh logic by consolidating language handling within the `tenderService`, ensuring consistent behavior across services. - Cleaned up related tests and removed deprecated functions to maintain code quality and readability. This update enhances the maintainability of the recommendation caching system by simplifying configuration and improving cache invalidation logic.
2000 lines
63 KiB
Go
2000 lines
63 KiB
Go
package tender
|
|
|
|
import (
|
|
"archive/zip"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
"tm/internal/company"
|
|
"tm/internal/customer"
|
|
"tm/pkg/ai_summarizer"
|
|
"tm/pkg/logger"
|
|
orm "tm/pkg/mongo"
|
|
"tm/pkg/redis"
|
|
"tm/pkg/response"
|
|
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
// AISummarizerClient defines the interface for on-demand AI operations.
|
|
type AISummarizerClient interface {
|
|
FetchSummaryOnDemand(ctx context.Context, reqBody ai_summarizer.SummarizeRequest) (*ai_summarizer.SummarizeResponse, error)
|
|
FetchTranslationOnDemand(ctx context.Context, reqBody ai_summarizer.TranslateRequest) (*ai_summarizer.TranslateResponse, error)
|
|
FetchAnalyzeOnDemand(ctx context.Context, reqBody ai_summarizer.AnalyzeRequest) (*ai_summarizer.AnalyzeResponse, error)
|
|
TriggerTranslateBatch(ctx context.Context, tenders []ai_summarizer.TenderRef, languages []string) (*ai_summarizer.BatchAcceptedResponse, error)
|
|
TriggerSummarizeBatch(ctx context.Context, tenders []ai_summarizer.TenderRef) (*ai_summarizer.BatchAcceptedResponse, error)
|
|
}
|
|
|
|
// AISummarizerStorage defines the interface for fetching summaries from MinIO storage.
|
|
// Paths follow the AI team's procedure-scoped layout: artefacts for a notice live under
|
|
// PROC_<contractFolderID>/<noticePublicationID>/ in the shared bucket.
|
|
type AISummarizerStorage interface {
|
|
GetSummaryFromStorage(ctx context.Context, contractFolderID, noticePublicationID string) (string, error)
|
|
GetDocumentSummariesFromStorage(ctx context.Context, contractFolderID, noticePublicationID string) ([]ai_summarizer.DocumentSummary, error)
|
|
GetTranslationFromStorage(ctx context.Context, contractFolderID, noticePublicationID, language string) (ai_summarizer.StoredTranslation, error)
|
|
ListDocuments(ctx context.Context, contractFolderID, noticePublicationID string) ([]ai_summarizer.StoredDocument, error)
|
|
DownloadDocument(ctx context.Context, objectName string) (io.ReadCloser, error)
|
|
}
|
|
|
|
// TenderDocumentDownload contains an authorized document stream.
|
|
type TenderDocumentDownload struct {
|
|
Reader io.ReadCloser
|
|
Filename string
|
|
ContentType string
|
|
Size int64
|
|
}
|
|
|
|
// TenderService interface defines tender business logic operations
|
|
type Service interface {
|
|
// Tender operations
|
|
GetByID(ctx context.Context, id string, language string) (*TenderResponse, error)
|
|
GetByIDs(ctx context.Context, ids []string) (map[string]*TenderResponse, error)
|
|
Update(ctx context.Context, req UpdateTenderRequest) (*TenderResponse, error)
|
|
Delete(ctx context.Context, id string) error
|
|
Search(ctx context.Context, req *SearchForm, pagination *response.Pagination) (*SearchResponse, error)
|
|
Recommend(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error)
|
|
GetDocumentSummaries(ctx context.Context, id string) ([]DocumentSummary, error)
|
|
GetDocuments(ctx context.Context, id string) (*TenderDocumentsResponse, error)
|
|
DownloadDocuments(ctx context.Context, id string) (*TenderDocumentDownload, error)
|
|
DownloadDocument(ctx context.Context, id, filename string) (*TenderDocumentDownload, error)
|
|
|
|
// AI summarization operations
|
|
GetAISummary(ctx context.Context, id string) (*AISummaryResponse, error)
|
|
TriggerAISummarize(ctx context.Context, id string) (*AISummarizeResponse, error)
|
|
TriggerAIAnalyze(ctx context.Context, id string) (*AIAnalyzeResponse, error)
|
|
TriggerAITranslate(ctx context.Context, id, language string) (*AITranslateResponse, error)
|
|
GetAllAISummaries(ctx context.Context) ([]ai_summarizer.TenderSummaryItem, error)
|
|
// ListTendersWithScrapedDocuments lists tenders that have documents in MinIO. When syncMongoMetadata is true,
|
|
// each tender is re-listed from MinIO and scraped document metadata is persisted to Mongo (slow).
|
|
ListTendersWithScrapedDocuments(ctx context.Context, pagination *response.Pagination, syncMongoMetadata bool) (*TendersWithScrapedDocumentsResponse, error)
|
|
// SyncScrapedDocumentsFromStorage lists documents in MinIO for a tender and persists scraped metadata to Mongo.
|
|
SyncScrapedDocumentsFromStorage(ctx context.Context, contractFolderID, noticePublicationID string) error
|
|
|
|
// Maintenance operations
|
|
UpdateExpiredTenders(ctx context.Context) error
|
|
CleanupOldData(ctx context.Context, olderThan time.Duration, pagination *response.Pagination) error
|
|
}
|
|
|
|
// tenderService implements TenderService interface
|
|
type tenderService struct {
|
|
repository TenderRepository
|
|
companyService company.Service
|
|
customerService customer.Service
|
|
rejectedTenderLister CompanyRejectedTenderLister
|
|
logger logger.Logger
|
|
aiSummarizerClient AISummarizerClient
|
|
aiSummarizerStorage AISummarizerStorage
|
|
redisClient redis.Client
|
|
pageCacheTTL time.Duration
|
|
defaultLanguage string
|
|
}
|
|
|
|
const (
|
|
aiSummaryEnrichmentTimeout = 2 * time.Second
|
|
aiTranslationEnrichmentTimeout = 2 * time.Second
|
|
)
|
|
|
|
var (
|
|
ErrTenderDocumentStorageUnavailable = errors.New("tender document storage is unavailable")
|
|
ErrTenderDocumentNotFound = errors.New("tender document not found")
|
|
)
|
|
|
|
// NewService creates a new tender service
|
|
func NewService(
|
|
repository TenderRepository,
|
|
companyService company.Service,
|
|
customerService customer.Service,
|
|
rejectedTenderLister CompanyRejectedTenderLister,
|
|
logger logger.Logger,
|
|
aiClient AISummarizerClient,
|
|
aiStorage AISummarizerStorage,
|
|
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),
|
|
}
|
|
}
|
|
|
|
// GetByID retrieves a tender by ID. When language is empty, the service default (usually en)
|
|
// is used to overlay title/description from MinIO translations when available.
|
|
func (s *tenderService) GetByID(ctx context.Context, id string, language string) (*TenderResponse, error) {
|
|
s.logger.Info("Retrieving tender by ID", map[string]interface{}{
|
|
"tender_id": id,
|
|
"language": s.resolveDetailLanguage(language),
|
|
})
|
|
|
|
tender, err := s.repository.GetByID(ctx, id)
|
|
if err != nil {
|
|
s.logger.Error("Failed to retrieve tender", map[string]interface{}{
|
|
"tender_id": id,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to retrieve tender: %w", err)
|
|
}
|
|
|
|
return s.buildDetailResponse(ctx, tender, language), nil
|
|
}
|
|
|
|
// GetByIDs loads multiple tenders for list endpoints without MinIO or AI enrichment.
|
|
func (s *tenderService) GetByIDs(ctx context.Context, ids []string) (map[string]*TenderResponse, error) {
|
|
if len(ids) == 0 {
|
|
return map[string]*TenderResponse{}, nil
|
|
}
|
|
|
|
tenders, err := s.repository.GetByIDs(ctx, ids)
|
|
if err != nil {
|
|
s.logger.Error("Failed to retrieve tenders by IDs", map[string]interface{}{
|
|
"count": len(ids),
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to retrieve tenders: %w", err)
|
|
}
|
|
|
|
out := make(map[string]*TenderResponse, len(tenders))
|
|
for i := range tenders {
|
|
resp := tenders[i].ToResponseWithLanguage("")
|
|
out[resp.ID] = resp
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// buildDetailResponse maps a tender to API form and enriches translation (MinIO) and summary.
|
|
func (s *tenderService) buildDetailResponse(ctx context.Context, tender *Tender, language string) *TenderResponse {
|
|
resp := tender.ToResponseWithLanguage("")
|
|
s.enrichWithTranslation(ctx, tender, resp, language, 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) {
|
|
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, 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
|
|
}
|
|
|
|
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,
|
|
})
|
|
|
|
// Profile keywords are not used for tender search.
|
|
|
|
if form.DocumentsScraped {
|
|
if err := s.enrichDocumentsScrapedFilter(ctx, form); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(form.ContractFolderIDsWithDocuments) == 0 {
|
|
return s.emptySearchResponse(pagination), nil
|
|
}
|
|
}
|
|
|
|
page, err := s.repository.Search(ctx, form, pagination)
|
|
if err != nil {
|
|
s.logger.Error("Failed to list tenders", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to list tenders: %w", err)
|
|
}
|
|
|
|
// If no company ID provided, return tenders without match percentage
|
|
if form.CompanyID == nil || *form.CompanyID == "" {
|
|
tenderResponses := make([]TenderResponse, 0, len(page.Items))
|
|
for _, tender := range page.Items {
|
|
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
|
|
}
|
|
|
|
return &SearchResponse{
|
|
Tenders: tenderResponses,
|
|
Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
|
|
}, nil
|
|
}
|
|
|
|
// Get company CPV codes for match calculation
|
|
comp, err := s.companyService.GetByID(ctx, *form.CompanyID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get company for match calculation", map[string]interface{}{
|
|
"company_id": *form.CompanyID,
|
|
"error": err.Error(),
|
|
})
|
|
// Continue without match percentage if company not found
|
|
tenderResponses := make([]TenderResponse, 0, len(page.Items))
|
|
for _, tender := range page.Items {
|
|
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
|
|
}
|
|
|
|
return &SearchResponse{
|
|
Tenders: tenderResponses,
|
|
Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
|
|
},
|
|
nil
|
|
}
|
|
|
|
var companyCPVCodes []string
|
|
if comp.Tags != nil && comp.Tags.CPVCodes != nil {
|
|
companyCPVCodes = comp.Tags.CPVCodes
|
|
}
|
|
|
|
s.logger.Info("Tenders sorted by match percentage", map[string]interface{}{
|
|
"company_id": *form.CompanyID,
|
|
"company_cpv_codes": len(companyCPVCodes),
|
|
"total_tenders": len(page.Items),
|
|
})
|
|
|
|
tenderResponses := make([]TenderResponse, 0, len(page.Items))
|
|
for _, tender := range page.Items {
|
|
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
|
|
}
|
|
|
|
return &SearchResponse{
|
|
Tenders: tenderResponses,
|
|
Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
|
|
},
|
|
nil
|
|
}
|
|
|
|
// Recommend retrieves AI-ranked tenders for the authenticated customer's company.
|
|
func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) {
|
|
companyIDs := normalizeRecommendationCompanyScope(form.CompanyIDs)
|
|
companyID := ""
|
|
if form.CompanyID != nil {
|
|
companyID = strings.TrimSpace(*form.CompanyID)
|
|
}
|
|
|
|
if companyID == "" && len(companyIDs) == 0 {
|
|
return nil, errors.New("company ID is required")
|
|
}
|
|
if pagination.Cursor != "" {
|
|
return nil, fmt.Errorf("%w: cursor pagination is not supported for recommendations", response.ErrInvalidPagination)
|
|
}
|
|
|
|
s.logger.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.buildRecommendedTenderResponsesParallel(ctx, pageResult.items, 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) enrichDocumentsScrapedFilter(ctx context.Context, form *SearchForm) error {
|
|
procedures, err := s.listProceduresWithDocuments(ctx)
|
|
if err != nil {
|
|
s.logger.Error("Failed to resolve tenders with scraped documents from MinIO", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) {
|
|
return fmt.Errorf("failed to resolve tenders with scraped documents: MinIO connection unavailable: %w", err)
|
|
}
|
|
return fmt.Errorf("failed to resolve tenders with scraped documents: %w", err)
|
|
}
|
|
|
|
folderIDs := make([]string, 0, len(procedures))
|
|
for _, proc := range procedures {
|
|
if proc.DocumentCount <= 0 {
|
|
continue
|
|
}
|
|
if id := strings.TrimSpace(proc.ContractFolderID); id != "" {
|
|
folderIDs = append(folderIDs, id)
|
|
}
|
|
}
|
|
form.ContractFolderIDsWithDocuments = folderIDs
|
|
return nil
|
|
}
|
|
|
|
func (s *tenderService) resolveRecommendedTenders(ctx context.Context, recommendations []company.RecommendedTenderResponse) (map[string]Tender, error) {
|
|
out := make(map[string]Tender)
|
|
procRefs := make([]ProcedureReference, 0, len(recommendations))
|
|
mongoIDs := make([]string, 0)
|
|
|
|
for _, rec := range recommendations {
|
|
ref := strings.TrimSpace(rec.TenderID)
|
|
if ref == "" {
|
|
continue
|
|
}
|
|
|
|
if folder, notice, ok := ParseAIProcedureRef(ref); ok {
|
|
procRefs = append(procRefs, ProcedureReference{
|
|
Ref: ref,
|
|
ContractFolderID: folder,
|
|
NoticePublicationID: notice,
|
|
})
|
|
continue
|
|
}
|
|
|
|
if IsMongoObjectIDRef(ref) {
|
|
mongoIDs = append(mongoIDs, ref)
|
|
continue
|
|
}
|
|
|
|
s.logger.Warn("Skipping unrecognized recommended tender reference", map[string]interface{}{
|
|
"tender_ref": ref,
|
|
})
|
|
}
|
|
|
|
var (
|
|
byProcedure = make(map[string]Tender)
|
|
byMongoID = make(map[string]Tender)
|
|
)
|
|
|
|
group, groupCtx := errgroup.WithContext(ctx)
|
|
if len(procRefs) > 0 {
|
|
group.Go(func() error {
|
|
byProcedureResult, err := s.repository.GetByProcedureReferences(groupCtx, procRefs)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load recommended tenders by procedure reference: %w", err)
|
|
}
|
|
for ref, tender := range byProcedureResult {
|
|
byProcedure[ref] = tender
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
if len(mongoIDs) > 0 {
|
|
group.Go(func() error {
|
|
tenders, err := s.repository.GetByIDs(groupCtx, mongoIDs)
|
|
if err != nil {
|
|
s.logger.Error("Failed to load recommended tenders by Mongo ID", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
return fmt.Errorf("failed to load recommended tenders: %w", err)
|
|
}
|
|
for i := range tenders {
|
|
byMongoID[tenders[i].GetID()] = tenders[i]
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
if err := group.Wait(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for ref, tender := range byProcedure {
|
|
out[ref] = tender
|
|
}
|
|
for ref, tender := range byMongoID {
|
|
out[ref] = tender
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func (s *tenderService) emptySearchResponse(pagination *response.Pagination) *SearchResponse {
|
|
return &SearchResponse{
|
|
Tenders: []TenderResponse{},
|
|
Metadata: pagination.ListMeta(0, "", false, pagination.Offset),
|
|
}
|
|
}
|
|
|
|
// SyncScrapedDocumentsFromStorage lists documents in MinIO for a tender and persists scraped metadata to Mongo.
|
|
func (s *tenderService) SyncScrapedDocumentsFromStorage(ctx context.Context, contractFolderID, noticePublicationID string) error {
|
|
contractFolderID = strings.TrimSpace(contractFolderID)
|
|
noticePublicationID = strings.TrimSpace(noticePublicationID)
|
|
if contractFolderID == "" {
|
|
return fmt.Errorf("contract folder ID is required")
|
|
}
|
|
|
|
var t *Tender
|
|
var err error
|
|
if noticePublicationID != "" {
|
|
t, err = s.repository.GetByNoticePublicationID(ctx, noticePublicationID)
|
|
} else {
|
|
byFolder, lookupErr := s.repository.GetLatestByContractFolderIDs(ctx, []string{contractFolderID})
|
|
err = lookupErr
|
|
if lookupErr == nil {
|
|
t = byFolder[contractFolderID]
|
|
}
|
|
}
|
|
if err != nil {
|
|
s.logger.Error("Failed to load tender for scraped document sync", map[string]interface{}{
|
|
"contract_folder_id": contractFolderID,
|
|
"notice_publication_id": noticePublicationID,
|
|
"error": err.Error(),
|
|
})
|
|
return fmt.Errorf("failed to load tender for scraped document sync: %w", err)
|
|
}
|
|
if t == nil {
|
|
s.logger.Warn("Skipping scraped document metadata sync: tender not found", map[string]interface{}{
|
|
"contract_folder_id": contractFolderID,
|
|
"notice_publication_id": noticePublicationID,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
docs, err := s.listDocumentsForTender(ctx, t)
|
|
if err != nil {
|
|
s.logger.Error("Failed to list scraped documents from storage", map[string]interface{}{
|
|
"tender_id": t.ID.Hex(),
|
|
"contract_folder_id": contractFolderID,
|
|
"notice_publication_id": noticePublicationID,
|
|
"error": err.Error(),
|
|
})
|
|
return fmt.Errorf("failed to list scraped documents from storage: %w", err)
|
|
}
|
|
if len(docs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
s.persistScrapedDocuments(ctx, t, docs)
|
|
return nil
|
|
}
|
|
|
|
// TriggerAITranslate triggers on-demand AI translation for a tender. Results are
|
|
// read from MinIO when available, otherwise fetched from the AI service (which persists to MinIO).
|
|
func (s *tenderService) TriggerAITranslate(ctx context.Context, id, language string) (*AITranslateResponse, error) {
|
|
if id == "" {
|
|
return nil, fmt.Errorf("tender ID cannot be empty")
|
|
}
|
|
if s.aiSummarizerClient == nil {
|
|
return nil, fmt.Errorf("AI summarizer service is not configured")
|
|
}
|
|
|
|
targetLanguage := strings.ToLower(strings.TrimSpace(language))
|
|
if targetLanguage == "" {
|
|
targetLanguage = s.defaultLanguage
|
|
}
|
|
|
|
t, err := s.repository.GetByID(ctx, id)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get tender for AI translation", map[string]interface{}{
|
|
"tender_id": id,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to get tender: %w", err)
|
|
}
|
|
if t == nil {
|
|
return nil, fmt.Errorf("tender not found")
|
|
}
|
|
if t.NoticePublicationID == "" {
|
|
return nil, fmt.Errorf("tender has no notice publication ID")
|
|
}
|
|
if strings.TrimSpace(t.ContractFolderID) == "" {
|
|
return nil, fmt.Errorf("tender has no contract folder ID")
|
|
}
|
|
|
|
title, description, fromAPI, err := s.resolveTranslation(ctx, t, targetLanguage, ai_summarizer.TranslationRequestSourceManualTrigger)
|
|
if err != nil {
|
|
s.logger.Error("AI translation failed", map[string]interface{}{
|
|
"tender_id": id,
|
|
"notice_id": t.NoticePublicationID,
|
|
"language": targetLanguage,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("AI translation request failed: %w", err)
|
|
}
|
|
|
|
if fromAPI {
|
|
s.logger.Info("Manual AI translation request succeeded", map[string]interface{}{
|
|
"tender_id": id,
|
|
"notice_id": t.NoticePublicationID,
|
|
"language": targetLanguage,
|
|
})
|
|
}
|
|
|
|
return s.buildAITranslateResponse(t, targetLanguage, title, description), nil
|
|
}
|
|
|
|
func (s *tenderService) buildAITranslateResponse(t *Tender, language, title, description string) *AITranslateResponse {
|
|
return &AITranslateResponse{
|
|
ContractFolderID: t.ContractFolderID,
|
|
NoticePublicationID: t.NoticePublicationID,
|
|
Language: language,
|
|
TranslatedTitle: title,
|
|
TranslatedDescription: description,
|
|
}
|
|
}
|
|
|
|
func (s *tenderService) resolveTranslation(ctx context.Context, t *Tender, language, requestSource string) (string, string, bool, error) {
|
|
if s.aiSummarizerStorage != nil {
|
|
stored, _, err := s.lookupTranslationForTender(ctx, t, language)
|
|
if err == nil && strings.TrimSpace(stored.Title) != "" {
|
|
return stored.Title, stored.Description, false, nil
|
|
}
|
|
if err != nil &&
|
|
!errors.Is(err, ai_summarizer.ErrTranslationNotReady) &&
|
|
!errors.Is(err, ai_summarizer.ErrObjectNotFound) &&
|
|
!errors.Is(err, ai_summarizer.ErrNoTranslation) {
|
|
s.logger.Debug("Translation not available from storage", map[string]interface{}{
|
|
"tender_id": t.ID.Hex(),
|
|
"language": language,
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
}
|
|
|
|
req := ai_summarizer.NewTranslateRequest(
|
|
t.ContractFolderID,
|
|
t.NoticePublicationID,
|
|
language,
|
|
t.Title,
|
|
t.Description,
|
|
)
|
|
req.RequestSource = requestSource
|
|
resp, err := s.aiSummarizerClient.FetchTranslationOnDemand(ctx, req)
|
|
if err != nil {
|
|
return "", "", false, err
|
|
}
|
|
|
|
title := strings.TrimSpace(resp.TranslatedTitle)
|
|
description := resp.TranslatedDescription
|
|
if s.aiSummarizerStorage != nil {
|
|
if stored, _, storageErr := s.lookupTranslationForTender(ctx, t, language); storageErr == nil && strings.TrimSpace(stored.Title) != "" {
|
|
title = stored.Title
|
|
description = stored.Description
|
|
}
|
|
}
|
|
if title == "" {
|
|
title = strings.TrimSpace(resp.TranslatedTitle)
|
|
}
|
|
|
|
return title, description, true, nil
|
|
}
|
|
|
|
func (s *tenderService) pickResponseLanguage(language *string) string {
|
|
if language != nil {
|
|
clean := strings.ToLower(strings.TrimSpace(*language))
|
|
if clean != "" {
|
|
return clean
|
|
}
|
|
}
|
|
return s.defaultLanguage
|
|
}
|