11e0b44ebe
continuous-integration/drone/push Build is passing
- Introduced a new method `isRecommendedPageCacheStale` in the `tenderService` to determine if the cached recommendations for a company are outdated based on the count of cached recommendations and the latest AI recommendations. - Updated the `recommendFromPageCache` method to utilize the new cache staleness check, ensuring that outdated caches are not used for recommendations. - This enhancement improves the accuracy of recommendations by ensuring that users receive the most up-to-date information, thereby enhancing the overall user experience in the tender recommendation service.
356 lines
9.9 KiB
Go
356 lines
9.9 KiB
Go
package tender
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
"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.buildRecommendedTenderListResponses(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) isRecommendedPageCacheStale(ctx context.Context, companyID string, cachedCount int) bool {
|
|
companyID = strings.TrimSpace(companyID)
|
|
if companyID == "" || cachedCount == 0 {
|
|
return false
|
|
}
|
|
|
|
recommendations, err := s.companyService.GetAIRecommendations(ctx, companyID)
|
|
if err != nil || len(recommendations) == 0 {
|
|
return false
|
|
}
|
|
|
|
return cachedCount < len(recommendations)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if len(companyIDs) == 0 && s.isRecommendedPageCacheStale(ctx, companyID, len(cached)) {
|
|
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)
|
|
}
|
|
|
|
paged := append([]TenderResponse(nil), filtered[start:end]...)
|
|
paged = s.enrichRecommendedTenderResponsesParallel(ctx, paged, lang)
|
|
|
|
return &SearchResponse{
|
|
Tenders: paged,
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|