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
+3
View File
@@ -233,6 +233,9 @@ type TenderResponse struct {
OverallSummary string `json:"overall_summary,omitempty"` OverallSummary string `json:"overall_summary,omitempty"`
Language string `json:"language,omitempty"` Language string `json:"language,omitempty"`
Rank string `json:"rank,omitempty"`
Analysis string `json:"analysis,omitempty"`
} }
// TenderDocumentResponse represents a scraped tender document available for download. // TenderDocumentResponse represents a scraped tender document available for download.
+22 -46
View File
@@ -7,6 +7,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"tm/internal/company"
"tm/pkg/logger" "tm/pkg/logger"
orm "tm/pkg/mongo" orm "tm/pkg/mongo"
"tm/pkg/response" "tm/pkg/response"
@@ -285,44 +286,18 @@ func (h *TenderHandler) GetPublicTenders(c echo.Context) error {
return response.SuccessWithMeta(c, result, result.Metadata, "Tenders retrieved successfully") return response.SuccessWithMeta(c, result, result.Metadata, "Tenders retrieved successfully")
} }
// GetPublicTenders retrieves public tenders for mobile app // RecommendTenders returns AI-ranked tender recommendations for the authenticated customer's company
// @Summary Get public tenders // @Summary Get recommended tenders
// @Description Retrieve active tenders for mobile application users with automatic company-based matching from customer profile // @Description Retrieve tenders ranked by opp-code overlap with the authenticated customer's company via the AI recommendation service
// @Tags Tenders // @Tags Tenders
// @Produce json // @Produce json
// @Param q query string false "Keyword search across title, description, AI document summaries, optional stored overall summary" // @Param limit query int false "Number of items per page (default: 10, max: 100)"
// @Param notice_type query string false "Filter by TED notice type code (notice_type_code)" // @Param offset query int false "Number of items to skip (default: 0)"
// @Param notice_types query []string false "Multiple notice type codes" // @Param lang query string false "Response language code (defaults to en)"
// @Param form_types query []string false "TED form types: competition, result, planning, contract-award, prior-information, etc."
// @Param procurement_type query string false "Filter by procurement type code"
// @Param country query string false "Single ISO country code"
// @Param country_codes query []string false "Filter by country codes"
// @Param status query []string false "Filter by status (comma-separated)"
// @Param source query []string false "Filter by source (comma-separated)"
// @Param classifications query []string false "CPV / classification codes"
// @Param cpv_codes query []string false "One or more CPV codes"
// @Param min_estimated_value query number false "Minimum estimated value"
// @Param max_estimated_value query number false "Maximum estimated value"
// @Param currency query string false "Filter by currency"
// @Param created_at_from query number false "Created at from (Unix seconds)"
// @Param created_at_to query number false "Created at to (Unix seconds)"
// @Param deadline_from query number false "Tender deadline from (Unix seconds)"
// @Param deadline_to query number false "Tender deadline to (Unix seconds)"
// @Param publication_date_from query number false "Publication date from (Unix seconds)"
// @Param publication_date_to query number false "Publication date to (Unix seconds)"
// @Param submission_date_from query number false "Submission deadline from (Unix seconds)"
// @Param submission_date_to query number false "Submission deadline to (Unix seconds)"
// @Param languages query []string false "Filter by languages (comma-separated)"
// @Param buyer_organization_id query string false "Filter by buyer organization ID"
// @Param notice_publication_id query string false "Filter by TED notice publication ID"
// @Param documents_scraped query bool false "When true, only tenders whose contract_folder_id has documents in MinIO"
// @Param limit query int false "Number of items per page (default: 20, max: 100)"
// @Param offset query int false "Number of items to skip (default: 0). Prefer cursor for deep pages."
// @Param cursor query string false "Opaque cursor from previous page meta.next_cursor"
// @Param sort_by query string false "Sort by field"
// @Param sort_order query string false "Sort order (asc or desc)"
// @Success 200 {object} response.APIResponse{data=SearchResponse} // @Success 200 {object} response.APIResponse{data=SearchResponse}
// @Failure 400 {object} response.APIResponse // @Failure 400 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 503 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse // @Failure 500 {object} response.APIResponse
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/tenders/recommend [get] // @Router /api/v1/tenders/recommend [get]
@@ -340,17 +315,9 @@ func (h *TenderHandler) RecommendTenders(c echo.Context) error {
form.Status = []string{string(TenderStatusActive)} form.Status = []string{string(TenderStatusActive)}
form.OnlyActiveDeadlines = true form.OnlyActiveDeadlines = true
var companyID *string if customerCompanyID, ok := c.Get("company_id").(string); ok && customerCompanyID != "" {
if customerCompanyID, ok := c.Get("company_id").(string); ok { form.CompanyID = &customerCompanyID
companyID = &customerCompanyID
} }
form.CompanyID = companyID
var customerID *string
if customerIDStr, ok := c.Get("customer_id").(string); ok {
customerID = &customerIDStr
}
form.CustomerID = customerID
pagination, err := response.NewPagination(c) pagination, err := response.NewPagination(c)
if err != nil { if err != nil {
@@ -362,10 +329,19 @@ func (h *TenderHandler) RecommendTenders(c echo.Context) error {
if response.IsListPaginationError(err) { if response.IsListPaginationError(err) {
return response.PaginationBadRequest(c, err) return response.PaginationBadRequest(c, err)
} }
return response.InternalServerError(c, "Failed to retrieve tenders") if errors.Is(err, company.ErrAIRecommendationNotConfigured) {
return response.ServiceUnavailable(c, "AI recommendation service is not configured")
}
if err.Error() == "company ID is required" {
return response.BadRequest(c, "Company ID is required", "")
}
if err.Error() == "company not found" {
return response.NotFound(c, "Company not found")
}
return response.InternalServerError(c, "Failed to retrieve recommended tenders")
} }
return response.SuccessWithMeta(c, result, result.Metadata, "Tenders retrieved successfully") return response.SuccessWithMeta(c, result, result.Metadata, "Recommended tenders retrieved successfully")
} }
// GetPublicTenderDetails retrieves public tender details for mobile app // GetPublicTenderDetails retrieves public tender details for mobile app
+63 -79
View File
@@ -844,32 +844,7 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
"to": form.DeadlineTo, "to": form.DeadlineTo,
}) })
// If customer ID is provided, get customer keywords and add to search // Profile keywords are not used for tender 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,
})
}
}
}
if form.DocumentsScraped { if form.DocumentsScraped {
if err := s.enrichDocumentsScrapedFilter(ctx, form); err != nil { if err := s.enrichDocumentsScrapedFilter(ctx, form); err != nil {
@@ -944,78 +919,87 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
nil 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) { func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) {
s.logger.Info("Recommend tenders", map[string]interface{}{ if form.CompanyID == nil || strings.TrimSpace(*form.CompanyID) == "" {
"company_id": form.CompanyID, return nil, errors.New("company ID is required")
"customer_id": form.CustomerID,
})
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,
})
}
} }
if pagination.Cursor != "" {
return nil, fmt.Errorf("%w: cursor pagination is not supported for recommendations", response.ErrInvalidPagination)
} }
// Fallback to company CPV codes if no customer keywords companyID := strings.TrimSpace(*form.CompanyID)
if form.Search == nil || *form.Search == "" { s.logger.Info("Recommend tenders via AI service", map[string]interface{}{
if form.CompanyID != nil && *form.CompanyID != "" { "company_id": companyID,
company, err := s.companyService.GetByID(ctx, *form.CompanyID) })
recommendations, err := s.companyService.GetAIRecommendations(ctx, companyID)
if err != nil { 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 return nil, err
} }
if len(form.ContractFolderIDsWithDocuments) == 0 { if len(recommendations) == 0 {
return s.emptySearchResponse(pagination), nil return s.emptySearchResponse(pagination), nil
} }
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
} }
page, err := s.repository.Search(ctx, form, pagination) tenders, err := s.repository.GetByIDs(ctx, tenderIDs)
if err != nil { if err != nil {
s.logger.Error("Failed to list tenders", map[string]interface{}{ s.logger.Error("Failed to load recommended tenders", map[string]interface{}{
"company_id": companyID,
"error": err.Error(), "error": err.Error(),
}) })
return nil, fmt.Errorf("failed to list tenders: %w", err) return nil, fmt.Errorf("failed to load recommended tenders: %w", err)
} }
tenderResponses := make([]TenderResponse, 0, len(page.Items)) tenderByID := make(map[string]Tender, len(tenders))
for _, tender := range page.Items { for i := range tenders {
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language))) 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{ return &SearchResponse{
Tenders: tenderResponses, Tenders: ordered[start:end],
Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset), Metadata: pagination.ListMeta(total, "", end < len(ordered), start),
}, nil }, nil
} }