Enhance tender response structure and update recommendation handler
continuous-integration/drone/push Build is passing

- Added new fields `Rank` and `Analysis` to the `TenderResponse` struct for improved data representation.
- Updated the `RecommendTenders` handler to reflect changes in API documentation, including a new summary and description for AI-ranked tender recommendations.
- Improved error handling in the recommendation process, ensuring appropriate responses for missing company IDs and unavailable AI services.

This update enhances the tender response capabilities and improves the clarity of the recommendation API, aligning with the overall goal of providing better insights for users.
This commit is contained in:
Mazyar
2026-06-21 12:29:37 +03:30
parent e04cdd1d10
commit 2538747768
3 changed files with 98 additions and 135 deletions
+73 -89
View File
@@ -844,32 +844,7 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
"to": form.DeadlineTo,
})
// If customer ID is provided, get customer keywords and add to search
if form.CustomerID != nil && *form.CustomerID != "" {
customerResp, err := s.customerService.GetByID(ctx, *form.CustomerID)
if err == nil && customerResp != nil {
// Only use keywords if status is ready
if customerResp.KeywordsStatus == "ready" && len(customerResp.Keywords) > 0 {
// Combine customer keywords with existing search query
keywordsQuery := strings.Join(customerResp.Keywords, " ")
if form.Search != nil && *form.Search != "" {
combinedSearch := *form.Search + " " + keywordsQuery
form.Search = &combinedSearch
} else {
form.Search = &keywordsQuery
}
s.logger.Info("Using customer keywords for search", map[string]interface{}{
"customer_id": *form.CustomerID,
"keywords": customerResp.Keywords,
})
} else {
s.logger.Info("Customer keywords not ready, skipping keyword-based search", map[string]interface{}{
"customer_id": *form.CustomerID,
"keywords_status": customerResp.KeywordsStatus,
})
}
}
}
// Profile keywords are not used for tender search.
if form.DocumentsScraped {
if err := s.enrichDocumentsScrapedFilter(ctx, form); err != nil {
@@ -944,78 +919,87 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
nil
}
// Recommend retrieves tenders with pagination and filtering
// Recommend retrieves AI-ranked tenders for the authenticated customer's company.
func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) {
s.logger.Info("Recommend tenders", map[string]interface{}{
"company_id": form.CompanyID,
"customer_id": form.CustomerID,
if form.CompanyID == nil || strings.TrimSpace(*form.CompanyID) == "" {
return nil, errors.New("company ID is required")
}
if pagination.Cursor != "" {
return nil, fmt.Errorf("%w: cursor pagination is not supported for recommendations", response.ErrInvalidPagination)
}
companyID := strings.TrimSpace(*form.CompanyID)
s.logger.Info("Recommend tenders via AI service", map[string]interface{}{
"company_id": companyID,
})
form.Status = []string{string(TenderStatusActive)}
// Priority: Use customer keywords if available, otherwise use company CPV codes
if form.CustomerID != nil && *form.CustomerID != "" {
customerResp, err := s.customerService.GetByID(ctx, *form.CustomerID)
if err == nil && customerResp != nil {
// Only use keywords if status is ready
if customerResp.KeywordsStatus == "ready" && len(customerResp.Keywords) > 0 {
// Use customer keywords for recommendation
keywordsQuery := strings.Join(customerResp.Keywords, " ")
form.Search = &keywordsQuery
s.logger.Info("Using customer keywords for recommendation", map[string]interface{}{
"customer_id": *form.CustomerID,
"keywords": customerResp.Keywords,
})
} else {
s.logger.Info("Customer keywords not ready, falling back to company CPV codes", map[string]interface{}{
"customer_id": *form.CustomerID,
"keywords_status": customerResp.KeywordsStatus,
})
}
}
}
// Fallback to company CPV codes if no customer keywords
if form.Search == nil || *form.Search == "" {
if form.CompanyID != nil && *form.CompanyID != "" {
company, err := s.companyService.GetByID(ctx, *form.CompanyID)
if err != nil {
s.logger.Error("Failed to get company for CPV codes, skipping CPV-based filtering", map[string]interface{}{
"company_id": form.CompanyID,
"error": err.Error(),
})
// Continue without CPV-based filtering instead of failing
} else if company.Tags != nil && len(company.Tags.CPVCodes) > 0 {
form.Classifications = company.Tags.CPVCodes
}
}
}
if form.DocumentsScraped {
if err := s.enrichDocumentsScrapedFilter(ctx, form); err != nil {
return nil, err
}
if len(form.ContractFolderIDsWithDocuments) == 0 {
return s.emptySearchResponse(pagination), nil
}
}
page, err := s.repository.Search(ctx, form, pagination)
recommendations, err := s.companyService.GetAIRecommendations(ctx, companyID)
if err != nil {
s.logger.Error("Failed to list tenders", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("failed to list tenders: %w", err)
return nil, err
}
if len(recommendations) == 0 {
return s.emptySearchResponse(pagination), nil
}
tenderResponses := make([]TenderResponse, 0, len(page.Items))
for _, tender := range page.Items {
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
tenderIDs := make([]string, 0, len(recommendations))
for _, rec := range recommendations {
if id := strings.TrimSpace(rec.TenderID); id != "" {
tenderIDs = append(tenderIDs, id)
}
}
if len(tenderIDs) == 0 {
return s.emptySearchResponse(pagination), nil
}
tenders, err := s.repository.GetByIDs(ctx, tenderIDs)
if err != nil {
s.logger.Error("Failed to load recommended tenders", map[string]interface{}{
"company_id": companyID,
"error": err.Error(),
})
return nil, fmt.Errorf("failed to load recommended tenders: %w", err)
}
tenderByID := make(map[string]Tender, len(tenders))
for i := range tenders {
tenderByID[tenders[i].GetID()] = tenders[i]
}
lang := s.pickResponseLanguage(form.Language)
now := time.Now().Unix()
ordered := make([]TenderResponse, 0, len(recommendations))
for _, rec := range recommendations {
tenderID := strings.TrimSpace(rec.TenderID)
tender, ok := tenderByID[tenderID]
if !ok {
continue
}
if form.OnlyActiveDeadlines && tender.TenderDeadline <= now {
continue
}
if len(form.Status) > 0 && tender.Status != TenderStatusActive {
continue
}
resp := tender.ToResponseWithLanguage(lang)
resp.Rank = rec.Rank
resp.Analysis = rec.Analysis
ordered = append(ordered, *resp)
}
total := int64(len(ordered))
start := pagination.Offset
if start > len(ordered) {
start = len(ordered)
}
end := start + pagination.Limit
if end > len(ordered) {
end = len(ordered)
}
return &SearchResponse{
Tenders: tenderResponses,
Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
Tenders: ordered[start:end],
Metadata: pagination.ListMeta(total, "", end < len(ordered), start),
}, nil
}