From fa258020f1a0ed19e3b47886127435fe1343a5a4 Mon Sep 17 00:00:00 2001 From: Mazyar Date: Sun, 21 Jun 2026 15:36:36 +0330 Subject: [PATCH] Add AI procedure reference formatting and parsing functions with unit tests - Introduced `FormatAIProcedureRef` and `ParseAIProcedureRef` functions in the `tender` domain for handling AI service tender references. - Added unit tests for these functions in `ai_reference_test.go` to ensure correct parsing and formatting behavior. - Updated the `TenderResponse` struct to include a new `ProcedureRef` field for improved data representation. - Enhanced the `GetByProcedureReference` method in the repository to retrieve tenders based on the new procedure reference format. - Modified the `Recommend` method in the service layer to utilize the new procedure reference handling, improving the recommendation process. This update enhances the handling of AI procedure references, ensuring better data integrity and usability in the tender management system. --- internal/tender/ai_reference.go | 56 +++++++++++++++++++ internal/tender/ai_reference_test.go | 49 ++++++++++++++++ internal/tender/form.go | 5 +- internal/tender/handler.go | 2 +- internal/tender/repository.go | 36 ++++++++++++ internal/tender/service.go | 83 ++++++++++++++++++++-------- 6 files changed, 205 insertions(+), 26 deletions(-) create mode 100644 internal/tender/ai_reference.go create mode 100644 internal/tender/ai_reference_test.go diff --git a/internal/tender/ai_reference.go b/internal/tender/ai_reference.go new file mode 100644 index 0000000..c3cdfa5 --- /dev/null +++ b/internal/tender/ai_reference.go @@ -0,0 +1,56 @@ +package tender + +import ( + "strings" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +const aiProcedurePrefix = "PROC_" + +// FormatAIProcedureRef builds the AI service tender reference: +// PROC_{contract_folder_id}/{notice_publication_id} +func FormatAIProcedureRef(contractFolderID, noticePublicationID string) string { + contractFolderID = strings.TrimSpace(contractFolderID) + noticePublicationID = strings.TrimSpace(noticePublicationID) + if contractFolderID == "" || noticePublicationID == "" { + return "" + } + return aiProcedurePrefix + contractFolderID + "/" + noticePublicationID +} + +// ParseAIProcedureRef parses PROC_{contract_folder_id}/{notice_publication_id}. +func ParseAIProcedureRef(ref string) (contractFolderID, noticePublicationID string, ok bool) { + ref = strings.TrimSpace(ref) + if ref == "" { + return "", "", false + } + + withoutPrefix := ref + if len(ref) >= len(aiProcedurePrefix) && strings.EqualFold(ref[:len(aiProcedurePrefix)], aiProcedurePrefix) { + withoutPrefix = ref[len(aiProcedurePrefix):] + } + + slash := strings.Index(withoutPrefix, "/") + if slash <= 0 || slash >= len(withoutPrefix)-1 { + return "", "", false + } + + contractFolderID = strings.TrimSpace(withoutPrefix[:slash]) + noticePublicationID = strings.TrimSpace(withoutPrefix[slash+1:]) + if contractFolderID == "" || noticePublicationID == "" { + return "", "", false + } + + return contractFolderID, noticePublicationID, true +} + +// IsMongoObjectIDRef reports whether ref is a 24-char hex MongoDB ObjectId. +func IsMongoObjectIDRef(ref string) bool { + ref = strings.TrimSpace(ref) + if len(ref) != 24 { + return false + } + _, err := bson.ObjectIDFromHex(ref) + return err == nil +} diff --git a/internal/tender/ai_reference_test.go b/internal/tender/ai_reference_test.go new file mode 100644 index 0000000..43e94c2 --- /dev/null +++ b/internal/tender/ai_reference_test.go @@ -0,0 +1,49 @@ +package tender + +import "testing" + +func TestParseAIProcedureRef(t *testing.T) { + tests := []struct { + ref string + folder string + notice string + ok bool + }{ + { + ref: "PROC_055329e0-3d8f-4eeb-946a-85a451f2e36b/00423458-2026", + folder: "055329e0-3d8f-4eeb-946a-85a451f2e36b", + notice: "00423458-2026", + ok: true, + }, + { + ref: "proc_d3d34785-e10f-4947-8d00-701816777bf5/00332053-2026", + folder: "d3d34785-e10f-4947-8d00-701816777bf5", + notice: "00332053-2026", + ok: true, + }, + {ref: "", ok: false}, + {ref: "507f1f77bcf86cd799439011", ok: false}, + {ref: "PROC_no-slash", ok: false}, + } + + for _, tt := range tests { + folder, notice, ok := ParseAIProcedureRef(tt.ref) + if ok != tt.ok { + t.Fatalf("ParseAIProcedureRef(%q) ok=%v want %v", tt.ref, ok, tt.ok) + } + if !tt.ok { + continue + } + if folder != tt.folder || notice != tt.notice { + t.Fatalf("ParseAIProcedureRef(%q) = (%q, %q) want (%q, %q)", tt.ref, folder, notice, tt.folder, tt.notice) + } + } +} + +func TestFormatAIProcedureRef(t *testing.T) { + got := FormatAIProcedureRef("055329e0-3d8f-4eeb-946a-85a451f2e36b", "00423458-2026") + want := "PROC_055329e0-3d8f-4eeb-946a-85a451f2e36b/00423458-2026" + if got != want { + t.Fatalf("FormatAIProcedureRef() = %q want %q", got, want) + } +} diff --git a/internal/tender/form.go b/internal/tender/form.go index 0e3dd45..d3d4efa 100644 --- a/internal/tender/form.go +++ b/internal/tender/form.go @@ -234,8 +234,9 @@ type TenderResponse struct { OverallSummary string `json:"overall_summary,omitempty"` Language string `json:"language,omitempty"` - Rank int `json:"rank,omitempty"` - Analysis string `json:"analysis,omitempty"` + Rank int `json:"rank,omitempty"` + Analysis string `json:"analysis,omitempty"` + ProcedureRef string `json:"procedure_ref,omitempty"` } // TenderDocumentResponse represents a scraped tender document available for download. diff --git a/internal/tender/handler.go b/internal/tender/handler.go index 41516e1..231cbf9 100644 --- a/internal/tender/handler.go +++ b/internal/tender/handler.go @@ -288,7 +288,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 tenders ranked by opp-code overlap with the authenticated customer's company via the AI recommendation service +// @Description Retrieve AI-ranked tenders with full tender details. 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)" diff --git a/internal/tender/repository.go b/internal/tender/repository.go index 42e0413..67b2583 100644 --- a/internal/tender/repository.go +++ b/internal/tender/repository.go @@ -21,6 +21,7 @@ type TenderRepository interface { GetByIDs(ctx context.Context, ids []string) ([]Tender, error) GetByContractNoticeID(ctx context.Context, contractNoticeID string) (*Tender, error) GetByNoticePublicationID(ctx context.Context, noticePublicationID string) (*Tender, error) + GetByProcedureReference(ctx context.Context, contractFolderID, noticePublicationID string) (*Tender, error) GetByContractFolderID(ctx context.Context, contractFolderID string) (*Tender, error) // GetLatestByContractFolderIDs returns the most recently updated tender per distinct contract_folder_id. GetLatestByContractFolderIDs(ctx context.Context, contractFolderIDs []string) (map[string]*Tender, error) @@ -297,6 +298,41 @@ func (r *tenderRepository) GetByNoticePublicationID(ctx context.Context, noticeP return &result.Items[0], nil } +// GetByProcedureReference retrieves a tender by contract folder and notice publication id. +// Matches canonical notice_publication_id and related_notice_publication_ids. +func (r *tenderRepository) GetByProcedureReference(ctx context.Context, contractFolderID, noticePublicationID string) (*Tender, error) { + contractFolderID = strings.TrimSpace(contractFolderID) + noticePublicationID = strings.TrimSpace(noticePublicationID) + if contractFolderID == "" || noticePublicationID == "" { + return nil, orm.ErrDocumentNotFound + } + + filter := bson.M{ + "contract_folder_id": contractFolderID, + "$or": []bson.M{ + {"notice_publication_id": noticePublicationID}, + {"related_notice_publication_ids": noticePublicationID}, + }, + } + pagination := orm.Pagination{Limit: 1, SortField: "updated_at", SortOrder: -1} + + result, err := r.ormRepo.FindAll(ctx, filter, pagination) + if err != nil { + r.logger.Error("Failed to get tender by procedure reference", map[string]interface{}{ + "contract_folder_id": contractFolderID, + "notice_publication_id": noticePublicationID, + "error": err.Error(), + }) + return nil, err + } + + if len(result.Items) == 0 { + return nil, orm.ErrDocumentNotFound + } + + return &result.Items[0], nil +} + // GetByContractFolderID retrieves a tender by its procedure-level identifier (ContractFolderID). // TED groups multiple notice publications (e.g. competition notice + result notice) under the same // ContractFolderID, so this lookup is the authoritative way to find the existing tender for a procedure diff --git a/internal/tender/service.go b/internal/tender/service.go index 27d693e..065e604 100644 --- a/internal/tender/service.go +++ b/internal/tender/service.go @@ -941,36 +941,17 @@ func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, paginat return s.emptySearchResponse(pagination), nil } - 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) + tenderByRef, err := s.resolveRecommendedTenders(ctx, recommendations) 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] + return nil, err } 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] + tenderRef := strings.TrimSpace(rec.TenderID) + tender, ok := tenderByRef[tenderRef] if !ok { continue } @@ -984,6 +965,7 @@ func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, paginat resp := tender.ToResponseWithLanguage(lang) resp.Rank = rec.Rank resp.Analysis = rec.Analysis + resp.ProcedureRef = tenderRef ordered = append(ordered, *resp) } @@ -1688,6 +1670,61 @@ func (s *tenderService) enrichDocumentsScrapedFilter(ctx context.Context, form * return nil } +func (s *tenderService) resolveRecommendedTenders(ctx context.Context, recommendations []company.RecommendedTenderResponse) (map[string]Tender, error) { + out := make(map[string]Tender) + mongoIDs := make([]string, 0) + + for _, rec := range recommendations { + ref := strings.TrimSpace(rec.TenderID) + if ref == "" { + continue + } + + if folder, notice, ok := ParseAIProcedureRef(ref); ok { + tender, err := s.repository.GetByProcedureReference(ctx, folder, notice) + if err != nil { + if errors.Is(err, orm.ErrDocumentNotFound) { + s.logger.Warn("Recommended tender not found by procedure reference", map[string]interface{}{ + "tender_ref": ref, + "contract_folder_id": folder, + "notice_publication_id": notice, + }) + continue + } + return nil, fmt.Errorf("failed to load recommended tender %s: %w", ref, err) + } + out[ref] = *tender + continue + } + + if IsMongoObjectIDRef(ref) { + mongoIDs = append(mongoIDs, ref) + continue + } + + s.logger.Warn("Skipping unrecognized recommended tender reference", map[string]interface{}{ + "tender_ref": ref, + }) + } + + if len(mongoIDs) == 0 { + return out, nil + } + + tenders, err := s.repository.GetByIDs(ctx, mongoIDs) + if err != nil { + s.logger.Error("Failed to load recommended tenders by Mongo ID", map[string]interface{}{ + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to load recommended tenders: %w", err) + } + for i := range tenders { + out[tenders[i].GetID()] = tenders[i] + } + + return out, nil +} + func (s *tenderService) emptySearchResponse(pagination *response.Pagination) *SearchResponse { return &SearchResponse{ Tenders: []TenderResponse{},