Enhance tender service to exclude company-rejected tenders from recommendations
- Updated the tender service to include a new dependency for listing rejected tenders by company, allowing for more refined tender recommendations. - Introduced a new field in the SearchForm to specify whether to exclude rejected tenders from the recommendation results. - Enhanced the Recommend method to filter out rejected tenders based on the new exclusion logic, improving the relevance of AI-ranked tender recommendations. - Added unit tests to verify the exclusion logic for rejected tenders, ensuring robust functionality. This update improves the tender recommendation process by ensuring that company-rejected tenders are not included in the results, enhancing user experience and satisfaction.
This commit is contained in:
@@ -65,6 +65,8 @@ type SearchForm struct {
|
||||
DocumentsScraped bool `query:"documents_scraped" valid:"optional"`
|
||||
// ContractFolderIDsWithDocuments is populated by the service from MinIO when DocumentsScraped is true.
|
||||
ContractFolderIDsWithDocuments []string `query:"-" valid:"optional"`
|
||||
// ExcludeRejectedTenders hides company-rejected tenders from recommendation results.
|
||||
ExcludeRejectedTenders bool `query:"-" valid:"optional"`
|
||||
}
|
||||
|
||||
// SearchResponse represents the response for listing tenders
|
||||
|
||||
@@ -326,7 +326,7 @@ func (h *TenderHandler) GetPublicTenders(c echo.Context) error {
|
||||
|
||||
// RecommendTenders returns AI-ranked tender recommendations for the authenticated customer's company
|
||||
// @Summary Get recommended tenders
|
||||
// @Description Retrieve AI-ranked tenders with full tender details. Only active tenders whose publication-based submission window has not passed are returned (submission_deadline, or 48 working hours after publication_date). AI tender_id values use PROC_{contract_folder_id}/{notice_publication_id}; each item includes rank, analysis, procedure_ref, and the standard tender fields.
|
||||
// @Description Retrieve AI-ranked tenders with full tender details. Only active tenders whose publication-based submission window has not passed are returned (submission_deadline, or 48 working hours after publication_date). Company-rejected tenders are excluded. AI tender_id values use PROC_{contract_folder_id}/{notice_publication_id}; each item includes rank, analysis, procedure_ref, and the standard tender fields.
|
||||
// @Tags Tenders
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of items per page (default: 10, max: 100)"
|
||||
@@ -352,6 +352,7 @@ func (h *TenderHandler) RecommendTenders(c echo.Context) error {
|
||||
|
||||
form.Status = []string{string(TenderStatusActive)}
|
||||
form.OnlyActiveDeadlines = true
|
||||
form.ExcludeRejectedTenders = true
|
||||
|
||||
if customerCompanyID, ok := c.Get("company_id").(string); ok && customerCompanyID != "" {
|
||||
form.CompanyID = &customerCompanyID
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package tender
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CompanyRejectedTenderLister returns tender IDs the company has rejected.
|
||||
type CompanyRejectedTenderLister interface {
|
||||
ListRejectedTenderIDsByCompanyID(ctx context.Context, companyID string) ([]string, error)
|
||||
}
|
||||
|
||||
func (s *tenderService) buildRejectedTenderIDSet(ctx context.Context, companyID string) (map[string]struct{}, error) {
|
||||
excluded := make(map[string]struct{})
|
||||
|
||||
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)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
set[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func isRecommendedTenderExcluded(tender Tender, tenderRef string, excluded map[string]struct{}) bool {
|
||||
if len(excluded) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, ok := excluded[tender.GetID()]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
tenderRef = strings.TrimSpace(tenderRef)
|
||||
if tenderRef != "" {
|
||||
if _, ok := excluded[tenderRef]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
procRef := FormatAIProcedureRef(tender.ContractFolderID, tender.NoticePublicationID)
|
||||
if procRef != "" {
|
||||
if _, ok := excluded[procRef]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package tender
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"tm/pkg/mongo"
|
||||
)
|
||||
|
||||
func TestIsRecommendedTenderExcluded(t *testing.T) {
|
||||
tender := Tender{
|
||||
Model: mongo.Model{},
|
||||
ContractFolderID: "055329e0-3d8f-4eeb-946a-85a451f2e36b",
|
||||
NoticePublicationID: "00423458-2026",
|
||||
}
|
||||
tender.ID, _ = bson.ObjectIDFromHex("507f1f77bcf86cd799439011")
|
||||
|
||||
procRef := FormatAIProcedureRef(tender.ContractFolderID, tender.NoticePublicationID)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
excluded map[string]struct{}
|
||||
tenderRef string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty exclusions",
|
||||
excluded: map[string]struct{}{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "excluded by mongo id",
|
||||
excluded: map[string]struct{}{tender.GetID(): {}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "excluded by procedure ref",
|
||||
excluded: map[string]struct{}{procRef: {}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "excluded by recommendation ref",
|
||||
excluded: map[string]struct{}{procRef: {}},
|
||||
tenderRef: procRef,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "not excluded",
|
||||
excluded: map[string]struct{}{"other-tender-id": {}},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isRecommendedTenderExcluded(tender, tt.tenderRef, tt.excluded)
|
||||
if got != tt.want {
|
||||
t.Fatalf("isRecommendedTenderExcluded() = %v want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+41
-15
@@ -80,13 +80,14 @@ type Service interface {
|
||||
|
||||
// tenderService implements TenderService interface
|
||||
type tenderService struct {
|
||||
repository TenderRepository
|
||||
companyService company.Service
|
||||
customerService customer.Service
|
||||
logger logger.Logger
|
||||
aiSummarizerClient AISummarizerClient
|
||||
aiSummarizerStorage AISummarizerStorage
|
||||
defaultLanguage string
|
||||
repository TenderRepository
|
||||
companyService company.Service
|
||||
customerService customer.Service
|
||||
rejectedTenderLister CompanyRejectedTenderLister
|
||||
logger logger.Logger
|
||||
aiSummarizerClient AISummarizerClient
|
||||
aiSummarizerStorage AISummarizerStorage
|
||||
defaultLanguage string
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -100,19 +101,29 @@ var (
|
||||
)
|
||||
|
||||
// NewService creates a new tender service
|
||||
func NewService(repository TenderRepository, companyService company.Service, customerService customer.Service, logger logger.Logger, aiClient AISummarizerClient, aiStorage AISummarizerStorage, defaultLanguage string) Service {
|
||||
func NewService(
|
||||
repository TenderRepository,
|
||||
companyService company.Service,
|
||||
customerService customer.Service,
|
||||
rejectedTenderLister CompanyRejectedTenderLister,
|
||||
logger logger.Logger,
|
||||
aiClient AISummarizerClient,
|
||||
aiStorage AISummarizerStorage,
|
||||
defaultLanguage string,
|
||||
) Service {
|
||||
if strings.TrimSpace(defaultLanguage) == "" {
|
||||
defaultLanguage = "en"
|
||||
}
|
||||
|
||||
return &tenderService{
|
||||
repository: repository,
|
||||
companyService: companyService,
|
||||
customerService: customerService,
|
||||
logger: logger,
|
||||
aiSummarizerClient: aiClient,
|
||||
aiSummarizerStorage: aiStorage,
|
||||
defaultLanguage: strings.ToLower(defaultLanguage),
|
||||
repository: repository,
|
||||
companyService: companyService,
|
||||
customerService: customerService,
|
||||
rejectedTenderLister: rejectedTenderLister,
|
||||
logger: logger,
|
||||
aiSummarizerClient: aiClient,
|
||||
aiSummarizerStorage: aiStorage,
|
||||
defaultLanguage: strings.ToLower(defaultLanguage),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -946,6 +957,18 @@ func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, paginat
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var excluded map[string]struct{}
|
||||
if form.ExcludeRejectedTenders {
|
||||
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(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to build recommendation exclusions: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
lang := s.pickResponseLanguage(form.Language)
|
||||
now := time.Now().Unix()
|
||||
ordered := make([]TenderResponse, 0, len(recommendations))
|
||||
@@ -955,6 +978,9 @@ func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, paginat
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if isRecommendedTenderExcluded(tender, tenderRef, excluded) {
|
||||
continue
|
||||
}
|
||||
if form.OnlyActiveDeadlines && !tender.IsRecommendable(now) {
|
||||
continue
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user