f68b6d7787
continuous-integration/drone/push Build is passing
- Removed the `buildRecommendedTenderResponsesParallel` method and replaced it with `buildRecommendedTenderListResponses`, simplifying the response building process. - Introduced `enrichRecommendedTenderResponsesParallel` to handle enrichment of tender responses in parallel, improving performance. - Updated the `Recommend` method to utilize the new enrichment logic, ensuring timely and efficient processing of recommendations. - Cleaned up unused functions and imports in `recommendation_page_cache.go` and `recommendation_response.go`, enhancing code clarity and maintainability. This update streamlines the recommendation response handling and improves the overall efficiency of the tender recommendation service.
338 lines
9.4 KiB
Go
338 lines
9.4 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) 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)
|
|
}
|
|
|
|
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)
|
|
}
|