Implement AI recommendations for multiple companies and enhance company context handling
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
- 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.
This commit is contained in:
@@ -101,6 +101,21 @@ func (h *Handler) StartOnboarding(c echo.Context) error {
|
|||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/recommend [post]
|
// @Router /api/v1/recommend [post]
|
||||||
func (h *Handler) RecommendTenders(c echo.Context) error {
|
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)
|
companyID, err := user.GetCompanyIDFromContext(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return response.Unauthorized(c, "User not authenticated")
|
return response.Unauthorized(c, "User not authenticated")
|
||||||
|
|||||||
@@ -24,6 +24,44 @@ const (
|
|||||||
recommendationFetchInitialDelay = 10 * time.Second
|
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 {
|
func aiRecommendationCacheKey(companyID string) string {
|
||||||
return aiRecommendationCacheKeyPrefix + companyID
|
return aiRecommendationCacheKeyPrefix + companyID
|
||||||
}
|
}
|
||||||
@@ -182,6 +220,29 @@ func (s *companyService) GetAIRecommendations(ctx context.Context, companyID str
|
|||||||
return []RecommendedTenderResponse{}, nil
|
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) {
|
func (s *companyService) scheduleRecommendationRefreshAfterOnboarding(companyID string) {
|
||||||
if s.aiRecommendationClient == nil {
|
if s.aiRecommendationClient == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,17 +44,18 @@ type Service interface {
|
|||||||
|
|
||||||
// GetAIRecommendations returns ranked tender recommendations from the AI team
|
// GetAIRecommendations returns ranked tender recommendations from the AI team
|
||||||
GetAIRecommendations(ctx context.Context, companyID string) ([]RecommendedTenderResponse, error)
|
GetAIRecommendations(ctx context.Context, companyID string) ([]RecommendedTenderResponse, error)
|
||||||
|
GetMergedAIRecommendations(ctx context.Context, companyIDs []string) ([]RecommendedTenderResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// companyService implements the Service interface
|
// companyService implements the Service interface
|
||||||
type companyService struct {
|
type companyService struct {
|
||||||
repository Repository
|
repository Repository
|
||||||
categoryService company_category.Service
|
categoryService company_category.Service
|
||||||
fileStore filestore.FileStoreService
|
fileStore filestore.FileStoreService
|
||||||
aiRecommendationClient AIRecommendationClient
|
aiRecommendationClient AIRecommendationClient
|
||||||
redisClient redis.Client
|
redisClient redis.Client
|
||||||
recommendationCacheTTL time.Duration
|
recommendationCacheTTL time.Duration
|
||||||
logger logger.Logger
|
logger logger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new company service
|
// NewService creates a new company service
|
||||||
@@ -68,7 +69,7 @@ func NewService(
|
|||||||
logger logger.Logger,
|
logger logger.Logger,
|
||||||
) Service {
|
) Service {
|
||||||
return &companyService{
|
return &companyService{
|
||||||
repository: repository,
|
repository: repository,
|
||||||
categoryService: categoryService,
|
categoryService: categoryService,
|
||||||
fileStore: fileStore,
|
fileStore: fileStore,
|
||||||
aiRecommendationClient: aiRecommendationClient,
|
aiRecommendationClient: aiRecommendationClient,
|
||||||
|
|||||||
@@ -8,10 +8,28 @@ import (
|
|||||||
|
|
||||||
var errCompanyNotAssigned = errors.New("company is not assigned to customer")
|
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.
|
// pickActiveCompanyID chooses the company context for a customer request.
|
||||||
// When requestedCompanyID is set it must be in assigned; otherwise a still-valid
|
// 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.
|
// token company is kept; if the token company was removed, the first assignment is used.
|
||||||
func pickActiveCompanyID(assigned []string, tokenCompanyID, requestedCompanyID string) (string, error) {
|
func pickActiveCompanyID(assigned []string, tokenCompanyID, requestedCompanyID string) (string, error) {
|
||||||
|
assigned = normalizeAssignedCompanyIDs(assigned)
|
||||||
if len(assigned) == 0 {
|
if len(assigned) == 0 {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
@@ -38,12 +56,24 @@ func pickActiveCompanyID(assigned []string, tokenCompanyID, requestedCompanyID s
|
|||||||
return assigned[0], nil
|
return assigned[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveActiveCompanyID resolves the active company for an authenticated customer.
|
// ResolveCompanyContext resolves the active company and assigned companies for an authenticated customer.
|
||||||
func (s *customerService) ResolveActiveCompanyID(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, error) {
|
func (s *customerService) ResolveCompanyContext(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, []string, error) {
|
||||||
customer, err := s.repository.GetByID(ctx, customerID)
|
customer, err := s.repository.GetByID(ctx, customerID)
|
||||||
if err != nil {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func (h *Handler) CompanyContextMiddleware() echo.MiddlewareFunc {
|
|||||||
tokenCompanyID, _ := c.Get("company_id").(string)
|
tokenCompanyID, _ := c.Get("company_id").(string)
|
||||||
requestedCompanyID := strings.TrimSpace(c.QueryParam("company_id"))
|
requestedCompanyID := strings.TrimSpace(c.QueryParam("company_id"))
|
||||||
|
|
||||||
companyID, err := h.service.ResolveActiveCompanyID(
|
companyID, companyIDs, err := h.service.ResolveCompanyContext(
|
||||||
c.Request().Context(),
|
c.Request().Context(),
|
||||||
customerID,
|
customerID,
|
||||||
tokenCompanyID,
|
tokenCompanyID,
|
||||||
@@ -90,6 +90,7 @@ func (h *Handler) CompanyContextMiddleware() echo.MiddlewareFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.Set("company_id", companyID)
|
c.Set("company_id", companyID)
|
||||||
|
c.Set("company_ids", companyIDs)
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ type Service interface {
|
|||||||
AssignRole(ctx context.Context, customerID string, form *AssignRoleForm) (*AssignRoleResponse, error)
|
AssignRole(ctx context.Context, customerID string, form *AssignRoleForm) (*AssignRoleResponse, error)
|
||||||
AssignCompanies(ctx context.Context, customerID string, Companies []string) 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 returns the company context for an authenticated customer.
|
||||||
ResolveActiveCompanyID(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, error)
|
ResolveActiveCompanyID(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type SearchForm struct {
|
|||||||
CountryCodes []string `query:"country_codes" valid:"optional"`
|
CountryCodes []string `query:"country_codes" valid:"optional"`
|
||||||
RegionCodes []string `query:"region_codes" valid:"optional"`
|
RegionCodes []string `query:"region_codes" valid:"optional"`
|
||||||
CompanyID *string `query:"company_id" valid:"optional"`
|
CompanyID *string `query:"company_id" valid:"optional"`
|
||||||
|
CompanyIDs []string `query:"-" valid:"optional"`
|
||||||
CustomerID *string `query:"customer_id" valid:"optional"`
|
CustomerID *string `query:"customer_id" valid:"optional"`
|
||||||
Status []string `query:"status" valid:"optional"`
|
Status []string `query:"status" valid:"optional"`
|
||||||
MainClassification *string `query:"main_classification" valid:"optional"`
|
MainClassification *string `query:"main_classification" valid:"optional"`
|
||||||
|
|||||||
@@ -354,7 +354,22 @@ func (h *TenderHandler) RecommendTenders(c echo.Context) error {
|
|||||||
form.OnlyActiveDeadlines = true
|
form.OnlyActiveDeadlines = true
|
||||||
form.ExcludeRejectedTenders = 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
|
form.CompanyID = &customerCompanyID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,4 +835,3 @@ func (h *TenderHandler) GetAllAISummaries(c echo.Context) error {
|
|||||||
|
|
||||||
return response.Success(c, summaries, "AI summaries retrieved successfully")
|
return response.Success(c, summaries, "AI summaries retrieved successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,23 @@ type CompanyRejectedTenderLister interface {
|
|||||||
ListRejectedTenderIDsByCompanyID(ctx context.Context, companyID string) ([]string, error)
|
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) {
|
func (s *tenderService) buildRejectedTenderIDSet(ctx context.Context, companyID string) (map[string]struct{}, error) {
|
||||||
excluded := make(map[string]struct{})
|
excluded := make(map[string]struct{})
|
||||||
|
|
||||||
@@ -27,6 +44,23 @@ func (s *tenderService) buildRejectedTenderIDSet(ctx context.Context, companyID
|
|||||||
return excluded, nil
|
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) {
|
func addTenderIDsToSet(set map[string]struct{}, ids []string) {
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
id = strings.TrimSpace(id)
|
id = strings.TrimSpace(id)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package tender
|
package tender
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
@@ -8,6 +9,14 @@ import (
|
|||||||
"tm/pkg/mongo"
|
"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) {
|
func TestIsRecommendedTenderExcluded(t *testing.T) {
|
||||||
tender := Tender{
|
tender := Tender{
|
||||||
Model: mongo.Model{},
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
// 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) {
|
||||||
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")
|
return nil, errors.New("company ID is required")
|
||||||
}
|
}
|
||||||
if pagination.Cursor != "" {
|
if pagination.Cursor != "" {
|
||||||
return nil, fmt.Errorf("%w: cursor pagination is not supported for recommendations", response.ErrInvalidPagination)
|
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{}{
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -959,11 +973,16 @@ func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, paginat
|
|||||||
|
|
||||||
var excluded map[string]struct{}
|
var excluded map[string]struct{}
|
||||||
if form.ExcludeRejectedTenders {
|
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 {
|
if err != nil {
|
||||||
s.logger.Error("Failed to build recommendation exclusions", map[string]interface{}{
|
s.logger.Error("Failed to build recommendation exclusions", map[string]interface{}{
|
||||||
"company_id": companyID,
|
"company_id": companyID,
|
||||||
"error": err.Error(),
|
"company_ids": companyIDs,
|
||||||
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
return nil, fmt.Errorf("failed to build recommendation exclusions: %w", err)
|
return nil, fmt.Errorf("failed to build recommendation exclusions: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user