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.
401 lines
11 KiB
Go
401 lines
11 KiB
Go
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)
|
|
}
|