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{},