diff --git a/internal/tender/form.go b/internal/tender/form.go index 20be21c..8826009 100644 --- a/internal/tender/form.go +++ b/internal/tender/form.go @@ -233,6 +233,9 @@ type TenderResponse struct { OverallSummary string `json:"overall_summary,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. diff --git a/internal/tender/handler.go b/internal/tender/handler.go index a6b3b89..41516e1 100644 --- a/internal/tender/handler.go +++ b/internal/tender/handler.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" "time" + "tm/internal/company" "tm/pkg/logger" orm "tm/pkg/mongo" "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") } -// GetPublicTenders retrieves public tenders for mobile app -// @Summary Get public tenders -// @Description Retrieve active tenders for mobile application users with automatic company-based matching from customer profile +// RecommendTenders returns AI-ranked tender recommendations for the authenticated customer's company +// @Summary Get recommended tenders +// @Description Retrieve tenders ranked by opp-code overlap with the authenticated customer's company via the AI recommendation service // @Tags Tenders // @Produce json -// @Param q query string false "Keyword search across title, description, AI document summaries, optional stored overall summary" -// @Param notice_type query string false "Filter by TED notice type code (notice_type_code)" -// @Param notice_types query []string false "Multiple notice type codes" -// @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)" +// @Param limit query int false "Number of items per page (default: 10, max: 100)" +// @Param offset query int false "Number of items to skip (default: 0)" +// @Param lang query string false "Response language code (defaults to en)" // @Success 200 {object} response.APIResponse{data=SearchResponse} // @Failure 400 {object} response.APIResponse +// @Failure 404 {object} response.APIResponse +// @Failure 503 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Security BearerAuth // @Router /api/v1/tenders/recommend [get] @@ -340,17 +315,9 @@ func (h *TenderHandler) RecommendTenders(c echo.Context) error { form.Status = []string{string(TenderStatusActive)} form.OnlyActiveDeadlines = true - var companyID *string - if customerCompanyID, ok := c.Get("company_id").(string); ok { - companyID = &customerCompanyID + if customerCompanyID, ok := c.Get("company_id").(string); ok && customerCompanyID != "" { + form.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) if err != nil { @@ -362,10 +329,19 @@ func (h *TenderHandler) RecommendTenders(c echo.Context) error { if response.IsListPaginationError(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 diff --git a/internal/tender/service.go b/internal/tender/service.go index 5b305c9..27d693e 100644 --- a/internal/tender/service.go +++ b/internal/tender/service.go @@ -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 }