From 1edf42187d14cc6369a5b4eeebecc69ad6d94704 Mon Sep 17 00:00:00 2001 From: Mazyar Date: Sat, 4 Jul 2026 13:00:33 +0330 Subject: [PATCH] Implement AI recommendations for multiple companies and enhance company context handling - Added functionality to retrieve merged AI recommendations for multiple companies, improving the relevance of tender suggestions based on company-specific data. - Introduced normalization functions to clean and deduplicate company IDs, ensuring accurate processing of recommendations. - Enhanced the company context resolution in customer middleware to support multiple assigned companies, improving the handling of company-specific requests. - Updated the tender recommendation logic to utilize the new merged recommendations and handle exclusions for rejected tenders accordingly. - Added unit tests to verify the new recommendation merging logic and company ID normalization, ensuring robust functionality. This update significantly enhances the tender recommendation process by allowing for more comprehensive and relevant suggestions based on multiple company contexts, improving user experience and satisfaction. --- internal/company/handler.go | 15 +++++ internal/company/recommendation.go | 61 +++++++++++++++++++ internal/company/recommendation_test.go | 44 +++++++++++++ internal/company/service.go | 17 +++--- internal/customer/company_context.go | 38 ++++++++++-- internal/customer/middleware.go | 3 +- internal/customer/service.go | 4 ++ internal/tender/form.go | 1 + internal/tender/handler.go | 18 +++++- internal/tender/recommendation_filter.go | 34 +++++++++++ internal/tender/recommendation_filter_test.go | 34 +++++++++++ internal/tender/service.go | 33 +++++++--- 12 files changed, 280 insertions(+), 22 deletions(-) create mode 100644 internal/company/recommendation_test.go diff --git a/internal/company/handler.go b/internal/company/handler.go index 0dbd924..b4d9516 100644 --- a/internal/company/handler.go +++ b/internal/company/handler.go @@ -101,6 +101,21 @@ func (h *Handler) StartOnboarding(c echo.Context) error { // @Security BearerAuth // @Router /api/v1/recommend [post] func (h *Handler) RecommendTenders(c echo.Context) error { + if companyIDs, ok := c.Get("company_ids").([]string); ok && len(companyIDs) > 0 { + result, err := h.service.GetMergedAIRecommendations(c.Request().Context(), companyIDs) + if err != nil { + if errors.Is(err, ErrAIRecommendationNotConfigured) { + return response.ServiceUnavailable(c, "AI recommendation service is not configured") + } + if err.Error() == "company not found" { + return response.NotFound(c, "Company not found") + } + return response.InternalServerError(c, "Failed to retrieve AI recommendations") + } + + return response.Success(c, result, "AI recommendations retrieved successfully") + } + companyID, err := user.GetCompanyIDFromContext(c) if err != nil { return response.Unauthorized(c, "User not authenticated") diff --git a/internal/company/recommendation.go b/internal/company/recommendation.go index 38e858c..3ff4191 100644 --- a/internal/company/recommendation.go +++ b/internal/company/recommendation.go @@ -24,6 +24,44 @@ const ( recommendationFetchInitialDelay = 10 * time.Second ) +func normalizeRecommendationCompanyIDs(companyIDs []string) []string { + normalized := make([]string, 0, len(companyIDs)) + seen := make(map[string]struct{}, len(companyIDs)) + for _, companyID := range companyIDs { + clean := strings.TrimSpace(companyID) + if clean == "" { + continue + } + if _, ok := seen[clean]; ok { + continue + } + seen[clean] = struct{}{} + normalized = append(normalized, clean) + } + return normalized +} + +func mergeRecommendedTenders(recommendationLists ...[]RecommendedTenderResponse) []RecommendedTenderResponse { + merged := make([]RecommendedTenderResponse, 0) + seen := make(map[string]struct{}) + for _, recommendations := range recommendationLists { + for _, recommendation := range recommendations { + tenderID := strings.TrimSpace(recommendation.TenderID) + if tenderID == "" { + continue + } + if _, ok := seen[tenderID]; ok { + continue + } + seen[tenderID] = struct{}{} + recommendation.TenderID = tenderID + recommendation.Analysis = strings.TrimSpace(recommendation.Analysis) + merged = append(merged, recommendation) + } + } + return merged +} + func aiRecommendationCacheKey(companyID string) string { return aiRecommendationCacheKeyPrefix + companyID } @@ -182,6 +220,29 @@ func (s *companyService) GetAIRecommendations(ctx context.Context, companyID str return []RecommendedTenderResponse{}, nil } +// GetMergedAIRecommendations unions cached AI recommendations across multiple companies. +func (s *companyService) GetMergedAIRecommendations(ctx context.Context, companyIDs []string) ([]RecommendedTenderResponse, error) { + if s.aiRecommendationClient == nil { + return nil, ErrAIRecommendationNotConfigured + } + + normalized := normalizeRecommendationCompanyIDs(companyIDs) + if len(normalized) == 0 { + return []RecommendedTenderResponse{}, nil + } + + recommendationLists := make([][]RecommendedTenderResponse, 0, len(normalized)) + for _, companyID := range normalized { + recommendations, err := s.GetAIRecommendations(ctx, companyID) + if err != nil { + return nil, err + } + recommendationLists = append(recommendationLists, recommendations) + } + + return mergeRecommendedTenders(recommendationLists...), nil +} + func (s *companyService) scheduleRecommendationRefreshAfterOnboarding(companyID string) { if s.aiRecommendationClient == nil { return diff --git a/internal/company/recommendation_test.go b/internal/company/recommendation_test.go new file mode 100644 index 0000000..4f02255 --- /dev/null +++ b/internal/company/recommendation_test.go @@ -0,0 +1,44 @@ +package company + +import "testing" + +func TestMergeRecommendedTenders(t *testing.T) { + first := []RecommendedTenderResponse{ + {Rank: 1, TenderID: " PROC-1 ", Analysis: " first "}, + {Rank: 2, TenderID: "PROC-2", Analysis: "second"}, + } + second := []RecommendedTenderResponse{ + {Rank: 1, TenderID: "PROC-2", Analysis: "duplicate"}, + {Rank: 3, TenderID: "PROC-3", Analysis: "third"}, + {Rank: 4, TenderID: " ", Analysis: "ignored"}, + } + + got := mergeRecommendedTenders(first, second) + if len(got) != 3 { + t.Fatalf("mergeRecommendedTenders() len = %d, want 3", len(got)) + } + + if got[0].TenderID != "PROC-1" || got[0].Analysis != "first" { + t.Fatalf("first recommendation = %+v, want trimmed PROC-1/first", got[0]) + } + if got[1].TenderID != "PROC-2" || got[1].Rank != 2 { + t.Fatalf("second recommendation = %+v, want original PROC-2 rank 2", got[1]) + } + if got[2].TenderID != "PROC-3" { + t.Fatalf("third recommendation = %+v, want PROC-3", got[2]) + } +} + +func TestNormalizeRecommendationCompanyIDs(t *testing.T) { + got := normalizeRecommendationCompanyIDs([]string{" company-a ", "", "company-b", "company-a"}) + want := []string{"company-a", "company-b"} + + if len(got) != len(want) { + t.Fatalf("normalizeRecommendationCompanyIDs() len = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("normalizeRecommendationCompanyIDs()[%d] = %q, want %q", i, got[i], want[i]) + } + } +} diff --git a/internal/company/service.go b/internal/company/service.go index 6ed8685..3a2a299 100644 --- a/internal/company/service.go +++ b/internal/company/service.go @@ -44,17 +44,18 @@ type Service interface { // GetAIRecommendations returns ranked tender recommendations from the AI team GetAIRecommendations(ctx context.Context, companyID string) ([]RecommendedTenderResponse, error) + GetMergedAIRecommendations(ctx context.Context, companyIDs []string) ([]RecommendedTenderResponse, error) } // companyService implements the Service interface type companyService struct { - repository Repository - categoryService company_category.Service - fileStore filestore.FileStoreService - aiRecommendationClient AIRecommendationClient - redisClient redis.Client - recommendationCacheTTL time.Duration - logger logger.Logger + repository Repository + categoryService company_category.Service + fileStore filestore.FileStoreService + aiRecommendationClient AIRecommendationClient + redisClient redis.Client + recommendationCacheTTL time.Duration + logger logger.Logger } // NewService creates a new company service @@ -68,7 +69,7 @@ func NewService( logger logger.Logger, ) Service { return &companyService{ - repository: repository, + repository: repository, categoryService: categoryService, fileStore: fileStore, aiRecommendationClient: aiRecommendationClient, diff --git a/internal/customer/company_context.go b/internal/customer/company_context.go index 58a6e63..ebfc339 100644 --- a/internal/customer/company_context.go +++ b/internal/customer/company_context.go @@ -8,10 +8,28 @@ import ( var errCompanyNotAssigned = errors.New("company is not assigned to customer") +func normalizeAssignedCompanyIDs(assigned []string) []string { + normalized := make([]string, 0, len(assigned)) + seen := make(map[string]struct{}, len(assigned)) + for _, id := range assigned { + clean := strings.TrimSpace(id) + if clean == "" { + continue + } + if _, ok := seen[clean]; ok { + continue + } + seen[clean] = struct{}{} + normalized = append(normalized, clean) + } + return normalized +} + // pickActiveCompanyID chooses the company context for a customer request. // When requestedCompanyID is set it must be in assigned; otherwise a still-valid // token company is kept; if the token company was removed, the first assignment is used. func pickActiveCompanyID(assigned []string, tokenCompanyID, requestedCompanyID string) (string, error) { + assigned = normalizeAssignedCompanyIDs(assigned) if len(assigned) == 0 { return "", nil } @@ -38,12 +56,24 @@ func pickActiveCompanyID(assigned []string, tokenCompanyID, requestedCompanyID s return assigned[0], nil } -// ResolveActiveCompanyID resolves the active company for an authenticated customer. -func (s *customerService) ResolveActiveCompanyID(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, error) { +// ResolveCompanyContext resolves the active company and assigned companies for an authenticated customer. +func (s *customerService) ResolveCompanyContext(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, []string, error) { customer, err := s.repository.GetByID(ctx, customerID) if err != nil { - return "", errors.New("customer not found") + return "", nil, errors.New("customer not found") } - return pickActiveCompanyID(customer.Companies, tokenCompanyID, requestedCompanyID) + assigned := normalizeAssignedCompanyIDs(customer.Companies) + companyID, err := pickActiveCompanyID(assigned, tokenCompanyID, requestedCompanyID) + if err != nil { + return "", nil, err + } + + return companyID, assigned, nil +} + +// ResolveActiveCompanyID resolves the active company for an authenticated customer. +func (s *customerService) ResolveActiveCompanyID(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, error) { + companyID, _, err := s.ResolveCompanyContext(ctx, customerID, tokenCompanyID, requestedCompanyID) + return companyID, err } diff --git a/internal/customer/middleware.go b/internal/customer/middleware.go index 76647b4..d8f0e12 100644 --- a/internal/customer/middleware.go +++ b/internal/customer/middleware.go @@ -76,7 +76,7 @@ func (h *Handler) CompanyContextMiddleware() echo.MiddlewareFunc { tokenCompanyID, _ := c.Get("company_id").(string) requestedCompanyID := strings.TrimSpace(c.QueryParam("company_id")) - companyID, err := h.service.ResolveActiveCompanyID( + companyID, companyIDs, err := h.service.ResolveCompanyContext( c.Request().Context(), customerID, tokenCompanyID, @@ -90,6 +90,7 @@ func (h *Handler) CompanyContextMiddleware() echo.MiddlewareFunc { } c.Set("company_id", companyID) + c.Set("company_ids", companyIDs) return next(c) } } diff --git a/internal/customer/service.go b/internal/customer/service.go index d917974..14ad7f0 100644 --- a/internal/customer/service.go +++ b/internal/customer/service.go @@ -54,6 +54,10 @@ type Service interface { AssignRole(ctx context.Context, customerID string, form *AssignRoleForm) (*AssignRoleResponse, error) AssignCompanies(ctx context.Context, customerID string, Companies []string) error + // ResolveCompanyContext returns the active company and all assigned companies + // for an authenticated customer request. + ResolveCompanyContext(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, []string, error) + // ResolveActiveCompanyID returns the company context for an authenticated customer. ResolveActiveCompanyID(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, error) } diff --git a/internal/tender/form.go b/internal/tender/form.go index 493c0bb..5055050 100644 --- a/internal/tender/form.go +++ b/internal/tender/form.go @@ -32,6 +32,7 @@ type SearchForm struct { CountryCodes []string `query:"country_codes" valid:"optional"` RegionCodes []string `query:"region_codes" valid:"optional"` CompanyID *string `query:"company_id" valid:"optional"` + CompanyIDs []string `query:"-" valid:"optional"` CustomerID *string `query:"customer_id" valid:"optional"` Status []string `query:"status" valid:"optional"` MainClassification *string `query:"main_classification" valid:"optional"` diff --git a/internal/tender/handler.go b/internal/tender/handler.go index 13b6574..eca3083 100644 --- a/internal/tender/handler.go +++ b/internal/tender/handler.go @@ -354,7 +354,22 @@ func (h *TenderHandler) RecommendTenders(c echo.Context) error { form.OnlyActiveDeadlines = true form.ExcludeRejectedTenders = true - if customerCompanyID, ok := c.Get("company_id").(string); ok && customerCompanyID != "" { + if customerIDStr, ok := c.Get("customer_id").(string); ok && customerIDStr != "" { + form.CustomerID = &customerIDStr + } + + requestedCompanyID := "" + if form.CompanyID != nil { + requestedCompanyID = strings.TrimSpace(*form.CompanyID) + } + + if requestedCompanyID != "" { + if customerCompanyID, ok := c.Get("company_id").(string); ok && customerCompanyID != "" { + form.CompanyID = &customerCompanyID + } + } else if customerCompanyIDs, ok := c.Get("company_ids").([]string); ok && len(customerCompanyIDs) > 0 { + form.CompanyIDs = append([]string(nil), customerCompanyIDs...) + } else if customerCompanyID, ok := c.Get("company_id").(string); ok && customerCompanyID != "" { form.CompanyID = &customerCompanyID } @@ -820,4 +835,3 @@ func (h *TenderHandler) GetAllAISummaries(c echo.Context) error { return response.Success(c, summaries, "AI summaries retrieved successfully") } - diff --git a/internal/tender/recommendation_filter.go b/internal/tender/recommendation_filter.go index d15a504..f55b517 100644 --- a/internal/tender/recommendation_filter.go +++ b/internal/tender/recommendation_filter.go @@ -11,6 +11,23 @@ type CompanyRejectedTenderLister interface { ListRejectedTenderIDsByCompanyID(ctx context.Context, companyID string) ([]string, error) } +func normalizeRecommendationCompanyScope(companyIDs []string) []string { + normalized := make([]string, 0, len(companyIDs)) + seen := make(map[string]struct{}, len(companyIDs)) + for _, companyID := range companyIDs { + clean := strings.TrimSpace(companyID) + if clean == "" { + continue + } + if _, ok := seen[clean]; ok { + continue + } + seen[clean] = struct{}{} + normalized = append(normalized, clean) + } + return normalized +} + func (s *tenderService) buildRejectedTenderIDSet(ctx context.Context, companyID string) (map[string]struct{}, error) { excluded := make(map[string]struct{}) @@ -27,6 +44,23 @@ func (s *tenderService) buildRejectedTenderIDSet(ctx context.Context, companyID return excluded, nil } +func (s *tenderService) buildRejectedTenderIDSetForCompanies(ctx context.Context, companyIDs []string) (map[string]struct{}, error) { + excluded := make(map[string]struct{}) + for _, companyID := range normalizeRecommendationCompanyScope(companyIDs) { + if s.rejectedTenderLister == nil { + return excluded, nil + } + + ids, err := s.rejectedTenderLister.ListRejectedTenderIDsByCompanyID(ctx, companyID) + if err != nil { + return nil, fmt.Errorf("failed to list rejected tender exclusions: %w", err) + } + + addTenderIDsToSet(excluded, ids) + } + return excluded, nil +} + func addTenderIDsToSet(set map[string]struct{}, ids []string) { for _, id := range ids { id = strings.TrimSpace(id) diff --git a/internal/tender/recommendation_filter_test.go b/internal/tender/recommendation_filter_test.go index dc1654f..6ce3937 100644 --- a/internal/tender/recommendation_filter_test.go +++ b/internal/tender/recommendation_filter_test.go @@ -1,6 +1,7 @@ package tender import ( + "context" "testing" "go.mongodb.org/mongo-driver/v2/bson" @@ -8,6 +9,14 @@ import ( "tm/pkg/mongo" ) +type stubRejectedTenderLister struct { + byCompany map[string][]string +} + +func (s stubRejectedTenderLister) ListRejectedTenderIDsByCompanyID(_ context.Context, companyID string) ([]string, error) { + return s.byCompany[companyID], nil +} + func TestIsRecommendedTenderExcluded(t *testing.T) { tender := Tender{ Model: mongo.Model{}, @@ -61,3 +70,28 @@ func TestIsRecommendedTenderExcluded(t *testing.T) { }) } } + +func TestBuildRejectedTenderIDSetForCompanies(t *testing.T) { + service := &tenderService{ + rejectedTenderLister: stubRejectedTenderLister{ + byCompany: map[string][]string{ + "company-a": {" tender-1 ", "tender-2"}, + "company-b": {"tender-2", "tender-3"}, + }, + }, + } + + got, err := service.buildRejectedTenderIDSetForCompanies(context.Background(), []string{"company-a", "company-b", "company-a"}) + if err != nil { + t.Fatalf("buildRejectedTenderIDSetForCompanies() error = %v", err) + } + + for _, tenderID := range []string{"tender-1", "tender-2", "tender-3"} { + if _, ok := got[tenderID]; !ok { + t.Fatalf("expected excluded set to contain %q", tenderID) + } + } + if len(got) != 3 { + t.Fatalf("buildRejectedTenderIDSetForCompanies() len = %d, want 3", len(got)) + } +} diff --git a/internal/tender/service.go b/internal/tender/service.go index 3f1817e..0ce99bd 100644 --- a/internal/tender/service.go +++ b/internal/tender/service.go @@ -932,19 +932,33 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination // 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) { - if form.CompanyID == nil || strings.TrimSpace(*form.CompanyID) == "" { + companyIDs := normalizeRecommendationCompanyScope(form.CompanyIDs) + companyID := "" + if form.CompanyID != nil { + companyID = strings.TrimSpace(*form.CompanyID) + } + + if companyID == "" && len(companyIDs) == 0 { 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, + "company_id": companyID, + "company_ids": companyIDs, }) - recommendations, err := s.companyService.GetAIRecommendations(ctx, companyID) + var ( + recommendations []company.RecommendedTenderResponse + err error + ) + if len(companyIDs) > 0 { + recommendations, err = s.companyService.GetMergedAIRecommendations(ctx, companyIDs) + } else { + recommendations, err = s.companyService.GetAIRecommendations(ctx, companyID) + } if err != nil { return nil, err } @@ -959,11 +973,16 @@ func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, paginat var excluded map[string]struct{} if form.ExcludeRejectedTenders { - excluded, err = s.buildRejectedTenderIDSet(ctx, companyID) + if len(companyIDs) > 0 { + excluded, err = s.buildRejectedTenderIDSetForCompanies(ctx, companyIDs) + } else { + excluded, err = s.buildRejectedTenderIDSet(ctx, companyID) + } if err != nil { s.logger.Error("Failed to build recommendation exclusions", map[string]interface{}{ - "company_id": companyID, - "error": err.Error(), + "company_id": companyID, + "company_ids": companyIDs, + "error": err.Error(), }) return nil, fmt.Errorf("failed to build recommendation exclusions: %w", err) }