package tender import ( "context" "encoding/json" "fmt" "strings" "time" "github.com/redis/go-redis/v9" "go.mongodb.org/mongo-driver/v2/bson" "golang.org/x/sync/errgroup" "tm/internal/company" "tm/pkg/response" ) const ( recommendedTendersPageCacheKeyPrefix = "ai:recommendations:page:" recommendationPageCacheReadTimeout = 3 * time.Second recommendationPageCacheBuildTimeout = 5 * time.Minute recommendationPageCacheConcurrency = 8 ) type cachedRecommendedTendersPage struct { BuiltAt int64 `json:"built_at"` CompanyID string `json:"company_id"` Language string `json:"language"` Items []TenderResponse `json:"items"` } func recommendedTendersPageCacheKey(companyID, language string) string { return recommendedTendersPageCacheKeyPrefix + strings.TrimSpace(companyID) + ":" + strings.ToLower(strings.TrimSpace(language)) } // ScheduleRefreshRecommendedTendersPageCache rebuilds the page cache for the service default language. func (s *tenderService) ScheduleRefreshRecommendedTendersPageCache(companyID string) { s.schedulePageCacheRefreshForLanguage(companyID, s.defaultLanguage) } // InvalidateRecommendedTendersPageCache drops the cached page for the service default language. func (s *tenderService) InvalidateRecommendedTendersPageCache(companyID string) { companyID = strings.TrimSpace(companyID) if companyID == "" || s.redisClient == nil { return } ctx, cancel := context.WithTimeout(context.Background(), recommendationPageCacheReadTimeout) defer cancel() s.deleteRecommendedTendersPageCache(ctx, companyID, s.defaultLanguage) } func (s *tenderService) schedulePageCacheRefreshForLanguage(companyID, language string) { companyID = strings.TrimSpace(companyID) language = strings.ToLower(strings.TrimSpace(language)) if companyID == "" || language == "" || s.redisClient == nil { return } go func() { ctx, cancel := context.WithTimeout(context.Background(), recommendationPageCacheBuildTimeout) defer cancel() if err := s.buildRecommendedTendersPageCache(ctx, companyID, language); err != nil { s.logger.Warn("Failed to refresh recommended tenders page cache", map[string]interface{}{ "company_id": companyID, "language": language, "error": err.Error(), }) } }() } func (s *tenderService) buildRecommendedTendersPageCache(ctx context.Context, companyID, language string) error { recommendations, err := s.companyService.GetAIRecommendations(ctx, companyID) if err != nil { return err } if len(recommendations) == 0 { s.deleteRecommendedTendersPageCache(ctx, companyID, language) return nil } items, err := s.buildAllRankedRecommendations(ctx, recommendations) if err != nil { return err } if len(items) == 0 { s.deleteRecommendedTendersPageCache(ctx, companyID, language) return nil } responses := s.buildRecommendedTenderResponsesParallel(ctx, items, language) page := cachedRecommendedTendersPage{ BuiltAt: time.Now().Unix(), CompanyID: companyID, Language: language, Items: responses, } encoded, err := json.Marshal(page) if err != nil { return fmt.Errorf("encode recommended tenders page cache: %w", err) } writeCtx, cancel := context.WithTimeout(ctx, recommendationPageCacheReadTimeout) defer cancel() if err := s.redisClient.Set(writeCtx, recommendedTendersPageCacheKey(companyID, language), string(encoded), s.pageCacheTTL); err != nil { return fmt.Errorf("store recommended tenders page cache: %w", err) } s.logger.Info("Recommended tenders page cache built", map[string]interface{}{ "company_id": companyID, "language": language, "count": len(responses), }) return nil } func (s *tenderService) getRecommendedTendersPageCache(ctx context.Context, companyID, language string) ([]TenderResponse, bool) { if s.redisClient == nil { return nil, false } readCtx, cancel := context.WithTimeout(ctx, recommendationPageCacheReadTimeout) defer cancel() raw, err := s.redisClient.Get(readCtx, recommendedTendersPageCacheKey(companyID, language)) if err != nil { if err != redis.Nil { s.logger.Warn("Failed to read recommended tenders page cache", map[string]interface{}{ "company_id": companyID, "language": language, "error": err.Error(), }) } return nil, false } var page cachedRecommendedTendersPage if err := json.Unmarshal([]byte(raw), &page); err != nil { s.logger.Warn("Failed to decode recommended tenders page cache", map[string]interface{}{ "company_id": companyID, "language": language, "error": err.Error(), }) s.deleteRecommendedTendersPageCache(ctx, companyID, language) return nil, false } if len(page.Items) == 0 { return nil, false } return page.Items, true } func (s *tenderService) deleteRecommendedTendersPageCache(ctx context.Context, companyID, language string) { if s.redisClient == nil { return } _ = s.redisClient.Del(ctx, recommendedTendersPageCacheKey(companyID, language)) } func (s *tenderService) buildAllRankedRecommendations( ctx context.Context, recommendations []company.RecommendedTenderResponse, ) ([]rankedRecommendedTender, error) { seenTenderIDs := make(map[string]struct{}, len(recommendations)) out := make([]rankedRecommendedTender, 0, len(recommendations)) for start := 0; start < len(recommendations); start += recommendationResolveBatchSize { end := start + recommendationResolveBatchSize if end > len(recommendations) { end = len(recommendations) } tenderByRef, err := s.resolveRecommendedTenders(ctx, recommendations[start:end]) if err != nil { return nil, err } for _, rec := range recommendations[start:end] { tenderRef := strings.TrimSpace(rec.TenderID) tender, ok := tenderByRef[tenderRef] if !ok { continue } if markResolvedRecommendedTenderSeen(seenTenderIDs, tender, tenderRef) { continue } out = append(out, rankedRecommendedTender{ tender: tender, rank: rec.Rank, analysis: rec.Analysis, tenderRef: tenderRef, }) } } return out, nil } func (s *tenderService) buildRecommendedTenderResponsesParallel( ctx context.Context, items []rankedRecommendedTender, language string, ) []TenderResponse { if len(items) == 0 { return nil } out := make([]TenderResponse, len(items)) if len(items) == 1 { out[0] = s.buildRecommendedTenderResponse( ctx, items[0].tender, items[0].rank, items[0].analysis, items[0].tenderRef, language, newRecommendationTranslationCache(), ) return out } translationCache := newRecommendationTranslationCache() group, groupCtx := errgroup.WithContext(ctx) group.SetLimit(recommendationPageCacheConcurrency) for i := range items { i := i item := items[i] group.Go(func() error { out[i] = s.buildRecommendedTenderResponse( groupCtx, item.tender, item.rank, item.analysis, item.tenderRef, language, translationCache, ) return nil }) } if err := group.Wait(); err != nil { s.logger.Warn("Parallel recommendation page cache build degraded to sequential", map[string]interface{}{ "error": err.Error(), "count": len(items), }) for i, item := range items { out[i] = s.buildRecommendedTenderResponse( ctx, item.tender, item.rank, item.analysis, item.tenderRef, language, translationCache, ) } } return out } func (s *tenderService) recommendFromPageCache( ctx context.Context, companyID string, companyIDs []string, form *SearchForm, pagination *response.Pagination, lang string, ) (*SearchResponse, bool) { var cached []TenderResponse if len(companyIDs) > 0 { cached = s.mergeCachedRecommendationPages(ctx, companyIDs, lang) } else { items, ok := s.getRecommendedTendersPageCache(ctx, companyID, lang) if !ok { return nil, false } cached = items } if len(cached) == 0 { return nil, false } excluded, excludedByCompany, err := s.loadRecommendationExclusions(ctx, form, companyID, companyIDs) if err != nil { return nil, false } now := time.Now().Unix() filtered := filterCachedRecommendedTenders(cached, form, companyIDs, excluded, excludedByCompany, now) total := int64(len(filtered)) start := pagination.Offset if start > len(filtered) { start = len(filtered) } end := start + pagination.Limit if end > len(filtered) { end = len(filtered) } return &SearchResponse{ Tenders: append([]TenderResponse(nil), filtered[start:end]...), Metadata: pagination.ListMeta(total, "", end < len(filtered), start), }, true } func (s *tenderService) mergeCachedRecommendationPages(ctx context.Context, companyIDs []string, lang string) []TenderResponse { normalized := normalizeRecommendationCompanyScope(companyIDs) if len(normalized) == 0 { return nil } lists := make([][]TenderResponse, 0, len(normalized)) for _, companyID := range normalized { items, ok := s.getRecommendedTendersPageCache(ctx, companyID, lang) if !ok || len(items) == 0 { return nil } lists = append(lists, items) } merged := make([]TenderResponse, 0) for _, list := range lists { merged = append(merged, list...) } return dedupeCachedRecommendedTendersByID(merged) } func dedupeCachedRecommendedTendersByID(items []TenderResponse) []TenderResponse { seen := make(map[string]struct{}, len(items)) out := make([]TenderResponse, 0, len(items)) for _, item := range items { key := strings.TrimSpace(item.ID) if key == "" { key = strings.TrimSpace(item.ProcedureRef) } if key == "" { continue } if _, exists := seen[key]; exists { continue } seen[key] = struct{}{} out = append(out, item) } return out } func filterCachedRecommendedTenders( items []TenderResponse, form *SearchForm, companyIDs []string, excluded map[string]struct{}, excludedByCompany map[string]map[string]struct{}, now int64, ) []TenderResponse { if len(items) == 0 { return nil } out := make([]TenderResponse, 0, len(items)) for _, item := range items { tender := tenderFromRecommendationResponse(item) tenderRef := strings.TrimSpace(item.ProcedureRef) if len(companyIDs) > 0 { if isRecommendedTenderExcludedForAllCompanies(tender, tenderRef, excludedByCompany, companyIDs) { continue } } else if isRecommendedTenderExcluded(tender, tenderRef, excluded) { continue } if form.OnlyActiveDeadlines && !tender.IsRecommendable(now) { continue } if len(form.Status) > 0 && !statusAllowed(tender.Status, form.Status) { continue } out = append(out, item) } return out } func tenderFromRecommendationResponse(item TenderResponse) Tender { var tender Tender if id, err := bson.ObjectIDFromHex(strings.TrimSpace(item.ID)); err == nil { tender.ID = id } tender.ContractFolderID = item.ContractFolderID tender.NoticePublicationID = item.NoticePublicationID tender.RelatedNoticePublicationIDs = append([]string(nil), item.RelatedNoticePublicationIDs...) tender.Status = item.Status tender.PublicationDate = item.PublicationDate tender.SubmissionDeadline = item.SubmissionDeadline tender.TenderDeadline = item.TenderDeadline return tender } // RecommendedTendersPageCacheKeyPrefix is shared with company invalidation logic. const RecommendedTendersPageCacheKeyPrefix = recommendedTendersPageCacheKeyPrefix // RecommendedTendersPageCacheKey builds the Redis key for a cached recommendation page. func RecommendedTendersPageCacheKey(companyID, language string) string { return recommendedTendersPageCacheKey(companyID, language) }