Enhance tender recommendation caching and page refresh logic
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
- Introduced a new `RecommendedTendersPageCacheRefresher` interface to manage the asynchronous refresh of recommendation pages in Redis, improving cache management. - Updated the `companyService` to support setting page cache languages and refreshing the recommended tenders page cache based on company IDs. - Enhanced the `tenderService` to build and invalidate recommended tenders page caches, ensuring timely updates and efficient retrieval of cached recommendations. - Added configuration options for recommendation page cache languages in the `AISummarizerConfig`, allowing for flexible language support. - Implemented unit tests for the new caching logic and page refresh functionality, ensuring robust validation of the recommendation caching process. This update significantly improves the efficiency and responsiveness of the tender recommendation service by integrating enhanced caching mechanisms and page refresh capabilities.
This commit is contained in:
@@ -73,6 +73,8 @@ type AISummarizerConfig struct {
|
||||
// RecommendationCacheTTL is an optional max Redis TTL for cached recommendations.
|
||||
// When 0 (default), entries persist until the company is created, updated, or onboarded.
|
||||
RecommendationCacheTTL time.Duration `env:"AI_RECOMMENDATION_CACHE_TTL" envDefault:"0"`
|
||||
// RecommendationPageCacheLanguages lists languages pre-built for the recommendation page cache.
|
||||
RecommendationPageCacheLanguages string `env:"AI_RECOMMENDATION_PAGE_CACHE_LANGUAGES" envDefault:"en"`
|
||||
|
||||
// MinIO storage settings
|
||||
MinioEndpoint string `env:"AI_SUMMARIZER_MINIO_ENDPOINT" envDefault:""`
|
||||
|
||||
+26
-1
@@ -241,8 +241,33 @@ func main() {
|
||||
userService := user.NewService(userRepository, logger, userAuthService, userValidator, notificationSDK, redisClient, auditLogger)
|
||||
categoryService := company_category.NewService(categoryRepository, logger)
|
||||
companyService := company.NewService(companyRepository, categoryService, fileStoreService, aiRecommendationClient, aiPipelineClient, redisClient, conf.AISummarizer.RecommendationCacheTTL, logger)
|
||||
pageCacheLanguages := tender.ParsePageCacheLanguagesConfig(conf.AISummarizer.RecommendationPageCacheLanguages)
|
||||
if configurer, ok := companyService.(interface {
|
||||
SetPageCacheLanguages([]string)
|
||||
}); ok {
|
||||
configurer.SetPageCacheLanguages(pageCacheLanguages)
|
||||
}
|
||||
customerService := customer.NewService(customerRepository, logger, customerAuthService, companyService, redisClient, notificationSDK, customerValidator, auditLogger)
|
||||
tenderService := tender.NewService(tenderRepository, companyService, customerService, tenderApprovalRepository, logger, aiSummarizerClient, aiSummarizerStorage, conf.AISummarizer.DefaultLanguage)
|
||||
tenderService := tender.NewService(
|
||||
tenderRepository,
|
||||
companyService,
|
||||
customerService,
|
||||
tenderApprovalRepository,
|
||||
logger,
|
||||
aiSummarizerClient,
|
||||
aiSummarizerStorage,
|
||||
redisClient,
|
||||
conf.AISummarizer.RecommendationCacheTTL,
|
||||
pageCacheLanguages,
|
||||
conf.AISummarizer.DefaultLanguage,
|
||||
)
|
||||
if configurer, ok := companyService.(interface {
|
||||
SetRecommendedTendersPageCacheRefresher(company.RecommendedTendersPageCacheRefresher)
|
||||
}); ok {
|
||||
if refresher, ok := tenderService.(company.RecommendedTendersPageCacheRefresher); ok {
|
||||
configurer.SetRecommendedTendersPageCacheRefresher(refresher)
|
||||
}
|
||||
}
|
||||
feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger)
|
||||
tenderApprovalService := tender_approval.NewService(tenderApprovalRepository, tenderService, logger)
|
||||
inquiryService := inquiry.NewService(inquiryRepository, notificationSDK, logger, xssPolicy)
|
||||
|
||||
@@ -121,6 +121,11 @@ func InitWorker(config Config, mongoManager *mongo.ConnectionManager, appLogger
|
||||
config.AISummarizer.RecommendationCacheTTL,
|
||||
appLogger,
|
||||
)
|
||||
if configurer, ok := recommendationRefresher.(interface {
|
||||
SetPageCacheLanguages([]string)
|
||||
}); ok {
|
||||
configurer.SetPageCacheLanguages(tender.ParsePageCacheLanguagesConfig(config.AISummarizer.RecommendationPageCacheLanguages))
|
||||
}
|
||||
appLogger.Info("Company recommendation refresh after pipeline enabled", map[string]interface{}{})
|
||||
} else if config.Worker.RecommendationRefreshAfterPipelineEnabled {
|
||||
appLogger.Warn("Company recommendation refresh after pipeline disabled: AI client or Redis unavailable", map[string]interface{}{})
|
||||
|
||||
@@ -60,4 +60,5 @@ type AISummarizerConfig struct {
|
||||
MinioRegion string `env:"AI_SUMMARIZER_MINIO_REGION" envDefault:"us-east-1"`
|
||||
MinioBucket string `env:"AI_SUMMARIZER_MINIO_BUCKET" envDefault:"opplens"`
|
||||
RecommendationCacheTTL time.Duration `env:"AI_RECOMMENDATION_CACHE_TTL" envDefault:"0"`
|
||||
RecommendationPageCacheLanguages string `env:"AI_RECOMMENDATION_PAGE_CACHE_LANGUAGES" envDefault:"en"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package company
|
||||
|
||||
// RecommendedTendersPageCacheRefresher pre-builds resolved recommendation pages in Redis.
|
||||
// Implemented by the tender service on the web API; optional on the worker.
|
||||
type RecommendedTendersPageCacheRefresher interface {
|
||||
ScheduleRefreshRecommendedTendersPageCache(companyID string)
|
||||
}
|
||||
@@ -464,7 +464,11 @@ func (s *companyService) cacheAIRecommendations(ctx context.Context, companyID s
|
||||
"company_id": companyID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.invalidateRecommendedTendersPageCache(ctx, companyID)
|
||||
s.scheduleRecommendedTendersPageCacheRefresh(companyID)
|
||||
}
|
||||
|
||||
func (s *companyService) recommendationCacheExpiration() time.Duration {
|
||||
@@ -485,6 +489,7 @@ func (s *companyService) invalidateAIRecommendationCache(ctx context.Context, co
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
s.invalidateRecommendedTendersPageCache(ctx, companyID)
|
||||
}
|
||||
|
||||
func (s *companyService) buildOnboardingDocuments(fileIDs []string) (string, error) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package company
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const recommendedTendersPageCacheKeyPrefix = "ai:recommendations:page:"
|
||||
|
||||
func recommendedTendersPageCacheKey(companyID, language string) string {
|
||||
return recommendedTendersPageCacheKeyPrefix +
|
||||
strings.TrimSpace(companyID) + ":" +
|
||||
strings.ToLower(strings.TrimSpace(language))
|
||||
}
|
||||
|
||||
func (s *companyService) invalidateRecommendedTendersPageCache(ctx context.Context, companyID string) {
|
||||
if s.redisClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, language := range s.pageCacheLanguages {
|
||||
lang := strings.ToLower(strings.TrimSpace(language))
|
||||
if lang == "" {
|
||||
continue
|
||||
}
|
||||
_ = s.redisClient.Del(ctx, recommendedTendersPageCacheKey(companyID, lang))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *companyService) scheduleRecommendedTendersPageCacheRefresh(companyID string) {
|
||||
if s.pageCacheRefresher == nil {
|
||||
return
|
||||
}
|
||||
s.pageCacheRefresher.ScheduleRefreshRecommendedTendersPageCache(companyID)
|
||||
}
|
||||
|
||||
// SetRecommendedTendersPageCacheRefresher wires async page-cache rebuilds from the tender service.
|
||||
func (s *companyService) SetRecommendedTendersPageCacheRefresher(refresher RecommendedTendersPageCacheRefresher) {
|
||||
s.pageCacheRefresher = refresher
|
||||
}
|
||||
|
||||
// SetPageCacheLanguages configures which language variants are invalidated in Redis.
|
||||
func (s *companyService) SetPageCacheLanguages(languages []string) {
|
||||
s.pageCacheLanguages = normalizePageCacheLanguages("", languages)
|
||||
}
|
||||
|
||||
func normalizePageCacheLanguages(defaultLanguage string, languages []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]string, 0, len(languages)+1)
|
||||
|
||||
add := func(lang string) {
|
||||
lang = strings.ToLower(strings.TrimSpace(lang))
|
||||
if lang == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[lang]; ok {
|
||||
return
|
||||
}
|
||||
seen[lang] = struct{}{}
|
||||
out = append(out, lang)
|
||||
}
|
||||
|
||||
add(defaultLanguage)
|
||||
for _, lang := range languages {
|
||||
add(lang)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
out = append(out, "en")
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -60,8 +60,10 @@ type companyService struct {
|
||||
fileStore filestore.FileStoreService
|
||||
aiRecommendationClient AIRecommendationClient
|
||||
aiPipelineStatusClient AIPipelineStatusClient
|
||||
pageCacheRefresher RecommendedTendersPageCacheRefresher
|
||||
redisClient redis.Client
|
||||
recommendationCacheTTL time.Duration
|
||||
pageCacheLanguages []string
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
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 resolved recommendation pages asynchronously.
|
||||
func (s *tenderService) ScheduleRefreshRecommendedTendersPageCache(companyID string) {
|
||||
companyID = strings.TrimSpace(companyID)
|
||||
if companyID == "" || s.redisClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), recommendationPageCacheBuildTimeout)
|
||||
defer cancel()
|
||||
|
||||
for _, language := range s.pageCacheLanguages {
|
||||
lang := strings.ToLower(strings.TrimSpace(language))
|
||||
if lang == "" {
|
||||
continue
|
||||
}
|
||||
if err := s.buildRecommendedTendersPageCache(ctx, companyID, lang); err != nil {
|
||||
s.logger.Warn("Failed to refresh recommended tenders page cache", map[string]interface{}{
|
||||
"company_id": companyID,
|
||||
"language": lang,
|
||||
"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
|
||||
}
|
||||
|
||||
func normalizePageCacheLanguages(defaultLanguage string, languages []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]string, 0, len(languages)+1)
|
||||
|
||||
add := func(lang string) {
|
||||
lang = strings.ToLower(strings.TrimSpace(lang))
|
||||
if lang == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[lang]; ok {
|
||||
return
|
||||
}
|
||||
seen[lang] = struct{}{}
|
||||
out = append(out, lang)
|
||||
}
|
||||
|
||||
add(defaultLanguage)
|
||||
for _, lang := range languages {
|
||||
add(lang)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
out = append(out, "en")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ParsePageCacheLanguagesConfig splits a comma-separated language list for cache prewarm.
|
||||
func ParsePageCacheLanguagesConfig(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return normalizePageCacheLanguages("en", nil)
|
||||
}
|
||||
return normalizePageCacheLanguages("", strings.Split(raw, ","))
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package tender
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestFilterCachedRecommendedTendersAppliesActiveAndRejectedFilters(t *testing.T) {
|
||||
activeID, _ := bson.ObjectIDFromHex("507f1f77bcf86cd799439011")
|
||||
expiredID, _ := bson.ObjectIDFromHex("507f1f77bcf86cd799439012")
|
||||
|
||||
items := []TenderResponse{
|
||||
{
|
||||
ID: activeID.Hex(),
|
||||
Status: TenderStatusActive,
|
||||
PublicationDate: 1,
|
||||
SubmissionDeadline: 9999999999,
|
||||
ProcedureRef: "PROC_folder-a/notice-a",
|
||||
Rank: 1,
|
||||
},
|
||||
{
|
||||
ID: expiredID.Hex(),
|
||||
Status: TenderStatusExpired,
|
||||
ProcedureRef: "PROC_folder-b/notice-b",
|
||||
Rank: 2,
|
||||
},
|
||||
}
|
||||
|
||||
form := &SearchForm{
|
||||
OnlyActiveDeadlines: true,
|
||||
ExcludeRejectedTenders: true,
|
||||
Status: []string{string(TenderStatusActive)},
|
||||
}
|
||||
excluded := map[string]struct{}{expiredID.Hex(): {}}
|
||||
|
||||
got := filterCachedRecommendedTenders(items, form, nil, excluded, nil, 100)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].ID != activeID.Hex() {
|
||||
t.Fatalf("got ID %q, want active tender", got[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePageCacheLanguagesConfig(t *testing.T) {
|
||||
got := ParsePageCacheLanguagesConfig("en,fr, en")
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
if got[0] != "en" || got[1] != "fr" {
|
||||
t.Fatalf("got %v, want [en fr]", got)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"tm/pkg/ai_summarizer"
|
||||
"tm/pkg/logger"
|
||||
orm "tm/pkg/mongo"
|
||||
"tm/pkg/redis"
|
||||
"tm/pkg/response"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
@@ -89,6 +90,9 @@ type tenderService struct {
|
||||
logger logger.Logger
|
||||
aiSummarizerClient AISummarizerClient
|
||||
aiSummarizerStorage AISummarizerStorage
|
||||
redisClient redis.Client
|
||||
pageCacheTTL time.Duration
|
||||
pageCacheLanguages []string
|
||||
defaultLanguage string
|
||||
}
|
||||
|
||||
@@ -111,6 +115,9 @@ func NewService(
|
||||
logger logger.Logger,
|
||||
aiClient AISummarizerClient,
|
||||
aiStorage AISummarizerStorage,
|
||||
redisClient redis.Client,
|
||||
recommendationPageCacheTTL time.Duration,
|
||||
recommendationPageCacheLanguages []string,
|
||||
defaultLanguage string,
|
||||
) Service {
|
||||
if strings.TrimSpace(defaultLanguage) == "" {
|
||||
@@ -125,6 +132,9 @@ func NewService(
|
||||
logger: logger,
|
||||
aiSummarizerClient: aiClient,
|
||||
aiSummarizerStorage: aiStorage,
|
||||
redisClient: redisClient,
|
||||
pageCacheTTL: recommendationPageCacheTTL,
|
||||
pageCacheLanguages: normalizePageCacheLanguages(defaultLanguage, recommendationPageCacheLanguages),
|
||||
defaultLanguage: strings.ToLower(defaultLanguage),
|
||||
}
|
||||
}
|
||||
@@ -967,6 +977,11 @@ func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, paginat
|
||||
"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
|
||||
@@ -983,6 +998,10 @@ func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, paginat
|
||||
return s.emptySearchResponse(pagination), nil
|
||||
}
|
||||
|
||||
if len(companyIDs) == 0 && companyID != "" {
|
||||
s.ScheduleRefreshRecommendedTendersPageCache(companyID)
|
||||
}
|
||||
|
||||
excluded, excludedByCompany, err := s.loadRecommendationExclusions(ctx, form, companyID, companyIDs)
|
||||
if err != nil {
|
||||
if form.ExcludeRejectedTenders {
|
||||
@@ -995,7 +1014,6 @@ func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, paginat
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lang := s.pickResponseLanguage(form.Language)
|
||||
now := time.Now().Unix()
|
||||
pageResult, err := s.buildRecommendationPage(
|
||||
ctx,
|
||||
|
||||
Reference in New Issue
Block a user