Add AI procedure reference formatting and parsing functions with unit tests
continuous-integration/drone/push Build is passing

- 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.
This commit is contained in:
Mazyar
2026-06-21 15:36:36 +03:30
parent 2b6e25f979
commit fa258020f1
6 changed files with 205 additions and 26 deletions
+56
View File
@@ -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
}
+49
View File
@@ -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)
}
}
+3 -2
View File
@@ -234,8 +234,9 @@ type TenderResponse struct {
OverallSummary string `json:"overall_summary,omitempty"` OverallSummary string `json:"overall_summary,omitempty"`
Language string `json:"language,omitempty"` Language string `json:"language,omitempty"`
Rank int `json:"rank,omitempty"` Rank int `json:"rank,omitempty"`
Analysis string `json:"analysis,omitempty"` Analysis string `json:"analysis,omitempty"`
ProcedureRef string `json:"procedure_ref,omitempty"`
} }
// TenderDocumentResponse represents a scraped tender document available for download. // TenderDocumentResponse represents a scraped tender document available for download.
+1 -1
View File
@@ -288,7 +288,7 @@ func (h *TenderHandler) GetPublicTenders(c echo.Context) error {
// RecommendTenders returns AI-ranked tender recommendations for the authenticated customer's company // RecommendTenders returns AI-ranked tender recommendations for the authenticated customer's company
// @Summary Get recommended tenders // @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 // @Tags Tenders
// @Produce json // @Produce json
// @Param limit query int false "Number of items per page (default: 10, max: 100)" // @Param limit query int false "Number of items per page (default: 10, max: 100)"
+36
View File
@@ -21,6 +21,7 @@ type TenderRepository interface {
GetByIDs(ctx context.Context, ids []string) ([]Tender, error) GetByIDs(ctx context.Context, ids []string) ([]Tender, error)
GetByContractNoticeID(ctx context.Context, contractNoticeID string) (*Tender, error) GetByContractNoticeID(ctx context.Context, contractNoticeID string) (*Tender, error)
GetByNoticePublicationID(ctx context.Context, noticePublicationID 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) GetByContractFolderID(ctx context.Context, contractFolderID string) (*Tender, error)
// GetLatestByContractFolderIDs returns the most recently updated tender per distinct contract_folder_id. // GetLatestByContractFolderIDs returns the most recently updated tender per distinct contract_folder_id.
GetLatestByContractFolderIDs(ctx context.Context, contractFolderIDs []string) (map[string]*Tender, error) 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 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). // GetByContractFolderID retrieves a tender by its procedure-level identifier (ContractFolderID).
// TED groups multiple notice publications (e.g. competition notice + result notice) under the same // 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 // ContractFolderID, so this lookup is the authoritative way to find the existing tender for a procedure
+60 -23
View File
@@ -941,36 +941,17 @@ func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, paginat
return s.emptySearchResponse(pagination), nil return s.emptySearchResponse(pagination), nil
} }
tenderIDs := make([]string, 0, len(recommendations)) tenderByRef, err := s.resolveRecommendedTenders(ctx, 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)
if err != nil { if err != nil {
s.logger.Error("Failed to load recommended tenders", map[string]interface{}{ return nil, err
"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]
} }
lang := s.pickResponseLanguage(form.Language) lang := s.pickResponseLanguage(form.Language)
now := time.Now().Unix() now := time.Now().Unix()
ordered := make([]TenderResponse, 0, len(recommendations)) ordered := make([]TenderResponse, 0, len(recommendations))
for _, rec := range recommendations { for _, rec := range recommendations {
tenderID := strings.TrimSpace(rec.TenderID) tenderRef := strings.TrimSpace(rec.TenderID)
tender, ok := tenderByID[tenderID] tender, ok := tenderByRef[tenderRef]
if !ok { if !ok {
continue continue
} }
@@ -984,6 +965,7 @@ func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, paginat
resp := tender.ToResponseWithLanguage(lang) resp := tender.ToResponseWithLanguage(lang)
resp.Rank = rec.Rank resp.Rank = rec.Rank
resp.Analysis = rec.Analysis resp.Analysis = rec.Analysis
resp.ProcedureRef = tenderRef
ordered = append(ordered, *resp) ordered = append(ordered, *resp)
} }
@@ -1688,6 +1670,61 @@ func (s *tenderService) enrichDocumentsScrapedFilter(ctx context.Context, form *
return nil 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 { func (s *tenderService) emptySearchResponse(pagination *response.Pagination) *SearchResponse {
return &SearchResponse{ return &SearchResponse{
Tenders: []TenderResponse{}, Tenders: []TenderResponse{},