Implement AI recommendations for multiple companies and enhance company context handling
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:
Mazyar
2026-07-04 13:00:33 +03:30
parent f8c98da132
commit 1edf42187d
12 changed files with 280 additions and 22 deletions
+15
View File
@@ -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")
+61
View File
@@ -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
+44
View File
@@ -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])
}
}
}
+9 -8
View File
@@ -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,