From 2d5df8556ffbeac6ea1a89fcc647aa5d573a3028 Mon Sep 17 00:00:00 2001 From: Mazyar Date: Tue, 19 May 2026 21:58:44 +0330 Subject: [PATCH] ai translation refactor --- internal/feedback/service.go | 4 +- internal/tender/form.go | 3 +- internal/tender/handler.go | 6 +- internal/tender/service.go | 116 +++++++++++++++++++++++++--- internal/tender_approval/service.go | 14 ++-- 5 files changed, 122 insertions(+), 21 deletions(-) diff --git a/internal/feedback/service.go b/internal/feedback/service.go index 4c260e0..a84c6af 100644 --- a/internal/feedback/service.go +++ b/internal/feedback/service.go @@ -191,7 +191,7 @@ func (s *feedbackService) GetByTenderID(ctx context.Context, tenderID, companyID return nil, err } - tender, err := s.tenderService.GetByID(ctx, feedback.TenderID) + tender, err := s.tenderService.GetByID(ctx, feedback.TenderID, "") if err != nil { s.logger.Error("Failed to get tender", map[string]interface{}{ "tender_id": feedback.TenderID, @@ -234,7 +234,7 @@ func (s *feedbackService) GetByTenderID(ctx context.Context, tenderID, companyID } func (s *feedbackService) getDetails(ctx context.Context, tenderID, companyID string) (*tenderResponse, *companyResponse, error) { - tender, err := s.tenderService.GetByID(ctx, tenderID) + tender, err := s.tenderService.GetByID(ctx, tenderID, "") if err != nil { s.logger.Error("Failed to get tender", map[string]interface{}{ "tender_id": tenderID, diff --git a/internal/tender/form.go b/internal/tender/form.go index 4b66b36..49765bb 100644 --- a/internal/tender/form.go +++ b/internal/tender/form.go @@ -255,7 +255,8 @@ func (t *Tender) ToResponse() *TenderResponse { return t.ToResponseWithLanguage("") } -// ToResponseWithLanguage converts a Tender entity to TenderResponse. +// ToResponseWithLanguage converts a Tender entity to TenderResponse using Mongo fields. +// Detail endpoints overlay MinIO translations in the service layer. func (t *Tender) ToResponseWithLanguage(language string) *TenderResponse { title := t.Title description := t.Description diff --git a/internal/tender/handler.go b/internal/tender/handler.go index 654c617..86e5dbe 100644 --- a/internal/tender/handler.go +++ b/internal/tender/handler.go @@ -38,6 +38,7 @@ func NewHandler(service Service, logger logger.Logger) *TenderHandler { // @Tags Admin-Tenders // @Produce json // @Param id path string true "Tender ID" +// @Param language query string false "Translation language code (defaults to en)" // @Success 200 {object} response.APIResponse{data=TenderResponse} // @Failure 404 {object} response.APIResponse // @Failure 500 {object} response.APIResponse @@ -49,7 +50,7 @@ func (h *TenderHandler) Get(c echo.Context) error { return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty") } - tender, err := h.service.GetByID(c.Request().Context(), id) + tender, err := h.service.GetByID(c.Request().Context(), id, c.QueryParam("language")) if err != nil { return response.NotFound(c, "Tender not found") } @@ -366,6 +367,7 @@ func (h *TenderHandler) RecommendTenders(c echo.Context) error { // @Tags Tenders // @Produce json // @Param id path string true "Tender ID" +// @Param language query string false "Translation language code (defaults to en)" // @Success 200 {object} response.APIResponse{data=map[string]interface{}} // @Failure 404 {object} response.APIResponse // @Failure 500 {object} response.APIResponse @@ -377,7 +379,7 @@ func (h *TenderHandler) GetPublicTenderDetails(c echo.Context) error { return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty") } - tender, err := h.service.GetByID(c.Request().Context(), id) + tender, err := h.service.GetByID(c.Request().Context(), id, c.QueryParam("language")) if err != nil { return response.NotFound(c, "Tender not found") } diff --git a/internal/tender/service.go b/internal/tender/service.go index 9bf1517..95e84b8 100644 --- a/internal/tender/service.go +++ b/internal/tender/service.go @@ -45,7 +45,7 @@ type TenderDocumentDownload struct { // TenderService interface defines tender business logic operations type Service interface { // Tender operations - GetByID(ctx context.Context, id string) (*TenderResponse, error) + GetByID(ctx context.Context, id string, language string) (*TenderResponse, error) Update(ctx context.Context, req UpdateTenderRequest) (*TenderResponse, error) Delete(ctx context.Context, id string) error Search(ctx context.Context, req *SearchForm, pagination *response.Pagination) (*SearchResponse, error) @@ -77,7 +77,10 @@ type tenderService struct { defaultLanguage string } -const aiSummaryEnrichmentTimeout = 2 * time.Second +const ( + aiSummaryEnrichmentTimeout = 2 * time.Second + aiTranslationEnrichmentTimeout = 2 * time.Second +) var ( ErrTenderDocumentStorageUnavailable = errors.New("tender document storage is unavailable") @@ -101,10 +104,12 @@ func NewService(repository TenderRepository, companyService company.Service, cus } } -// GetTenderByID retrieves a tender by ID -func (s *tenderService) GetByID(ctx context.Context, id string) (*TenderResponse, error) { +// GetByID retrieves a tender by ID. When language is empty, the service default (usually en) +// is used to overlay title/description from MinIO translations when available. +func (s *tenderService) GetByID(ctx context.Context, id string, language string) (*TenderResponse, error) { s.logger.Info("Retrieving tender by ID", map[string]interface{}{ "tender_id": id, + "language": s.resolveDetailLanguage(language), }) tender, err := s.repository.GetByID(ctx, id) @@ -116,12 +121,15 @@ func (s *tenderService) GetByID(ctx context.Context, id string) (*TenderResponse return nil, fmt.Errorf("failed to retrieve tender: %w", err) } - resp := tender.ToResponseWithLanguage(s.defaultLanguage) + return s.buildDetailResponse(ctx, tender, language), nil +} - // Enrich with AI summary from MinIO storage (best-effort, non-blocking) +// buildDetailResponse maps a tender to API form and enriches translation (MinIO) and summary. +func (s *tenderService) buildDetailResponse(ctx context.Context, tender *Tender, language string) *TenderResponse { + resp := tender.ToResponseWithLanguage("") + s.enrichWithTranslation(ctx, tender, resp, language) s.enrichWithAISummary(ctx, tender, resp) - - return resp, nil + return resp } // enrichWithAISummary loads overall_summary from MinIO (PROC_//tender.json) @@ -164,6 +172,96 @@ func (s *tenderService) enrichWithAISummary(ctx context.Context, t *Tender, resp s.persistAIOverallSummary(enrichCtx, t, summary) } +// enrichWithTranslation overlays title/description from MinIO when the pipeline has finished +// for the requested language. Does not call the AI translate API on GET. +func (s *tenderService) enrichWithTranslation(ctx context.Context, t *Tender, resp *TenderResponse, language string) { + if t == nil || resp == nil { + return + } + if s.aiSummarizerStorage == nil || strings.TrimSpace(t.ContractFolderID) == "" { + return + } + + targetLanguage := s.resolveDetailLanguage(language) + enrichCtx, cancel := context.WithTimeout(ctx, aiTranslationEnrichmentTimeout) + defer cancel() + + stored, usedNotice, err := s.lookupTranslationForTender(enrichCtx, t, targetLanguage) + if err != nil { + s.logger.Debug("Translation not available for tender detail", map[string]interface{}{ + "tender_id": resp.ID, + "contract_folder_id": t.ContractFolderID, + "notice_id": t.NoticePublicationID, + "language": targetLanguage, + "error": err.Error(), + }) + return + } + if strings.TrimSpace(stored.Title) == "" { + return + } + + resp.Title = stored.Title + resp.Description = stored.Description + resp.Language = targetLanguage + if usedNotice != t.NoticePublicationID { + s.logger.Debug("Translation resolved from related notice", map[string]interface{}{ + "tender_id": resp.ID, + "contract_folder_id": t.ContractFolderID, + "primary_notice_id": t.NoticePublicationID, + "used_notice_id": usedNotice, + "language": targetLanguage, + }) + } +} + +// lookupTranslationForTender returns stored translation text, trying primary then related notice ids. +func (s *tenderService) lookupTranslationForTender(ctx context.Context, t *Tender, language string) (ai_summarizer.StoredTranslation, string, error) { + if s.aiSummarizerStorage == nil { + return ai_summarizer.StoredTranslation{}, "", fmt.Errorf("AI summarizer storage is not configured") + } + candidates := orderedNoticeCandidates(t) + if len(candidates) == 0 { + return ai_summarizer.StoredTranslation{}, "", ai_summarizer.ErrObjectNotFound + } + + var lastErr error + for _, noticeID := range candidates { + stored, err := s.aiSummarizerStorage.GetTranslationFromStorage(ctx, t.ContractFolderID, noticeID, language) + if err == nil { + return stored, noticeID, nil + } + lastErr = err + if !errors.Is(err, ai_summarizer.ErrTranslationNotReady) && + !errors.Is(err, ai_summarizer.ErrObjectNotFound) && + !errors.Is(err, ai_summarizer.ErrNoTranslation) { + if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) { + s.logger.Error("MinIO unavailable while fetching translation", map[string]interface{}{ + "tender_id": t.ID.Hex(), + "contract_folder_id": t.ContractFolderID, + "notice_id": noticeID, + "language": language, + "error": err.Error(), + }) + } + return ai_summarizer.StoredTranslation{}, "", err + } + } + if lastErr == nil { + lastErr = ai_summarizer.ErrObjectNotFound + } + return ai_summarizer.StoredTranslation{}, "", lastErr +} + +// resolveDetailLanguage returns the language code used for detail responses. +func (s *tenderService) resolveDetailLanguage(language string) string { + clean := strings.ToLower(strings.TrimSpace(language)) + if clean == "" { + return s.defaultLanguage + } + return clean +} + // persistAIOverallSummary stores the resolved summary on the tender for list/search responses. func (s *tenderService) persistAIOverallSummary(ctx context.Context, t *Tender, summary string) { summary = strings.TrimSpace(summary) @@ -300,7 +398,7 @@ func (s *tenderService) Update(ctx context.Context, req UpdateTenderRequest) (*T "tender_id": tender.ID, }) - return tender.ToResponseWithLanguage(s.defaultLanguage), nil + return s.buildDetailResponse(ctx, tender, ""), nil } // Delete deletes a tender diff --git a/internal/tender_approval/service.go b/internal/tender_approval/service.go index 072fcd3..3f196da 100644 --- a/internal/tender_approval/service.go +++ b/internal/tender_approval/service.go @@ -134,7 +134,7 @@ func (s *tenderApprovalService) GetTenderApprovalByIDWithTender(ctx context.Cont } // Get tender details - tenderResponse, err := s.tenderService.GetByID(ctx, tenderApproval.TenderID) + tenderResponse, err := s.tenderService.GetByID(ctx, tenderApproval.TenderID, "") if err != nil { s.logger.Warn("Failed to get tender details", map[string]interface{}{ "tender_id": tenderApproval.TenderID, @@ -205,7 +205,7 @@ func (s *tenderApprovalService) GetTenderApprovalByTenderAndCompanyWithTender(ct } // Get tender details - tenderResponse, err := s.tenderService.GetByID(ctx, tenderApproval.TenderID) + tenderResponse, err := s.tenderService.GetByID(ctx, tenderApproval.TenderID, "") if err != nil { s.logger.Warn("Failed to get tender details", map[string]interface{}{ "tender_id": tenderApproval.TenderID, @@ -299,7 +299,7 @@ func (s *tenderApprovalService) GetTenderApprovalsByCompanyIDWithTender(ctx cont } for _, approval := range tenderApprovals { // Get tender details - tenderResponse, err := s.tenderService.GetByID(ctx, approval.TenderID) + tenderResponse, err := s.tenderService.GetByID(ctx, approval.TenderID, "") if err != nil { s.logger.Warn("Failed to get tender details", map[string]interface{}{ "tender_id": approval.TenderID, @@ -444,7 +444,7 @@ func (s *tenderApprovalService) UpdateTenderApprovalWithTender(ctx context.Conte } // Get tender details - tenderResponse, err := s.tenderService.GetByID(ctx, tenderApproval.TenderID) + tenderResponse, err := s.tenderService.GetByID(ctx, tenderApproval.TenderID, "") if err != nil { s.logger.Warn("Failed to get tender details", map[string]interface{}{ "tender_id": tenderApproval.TenderID, @@ -784,7 +784,7 @@ func (s *tenderApprovalService) GetTenderApprovalsByStatusWithTender(ctx context var responses []*TenderApprovalWithTenderResponse for _, approval := range tenderApprovals { // Get tender details - tenderResponse, err := s.tenderService.GetByID(ctx, approval.TenderID) + tenderResponse, err := s.tenderService.GetByID(ctx, approval.TenderID, "") if err != nil { s.logger.Warn("Failed to get tender details", map[string]interface{}{ "tender_id": approval.TenderID, @@ -832,7 +832,7 @@ func (s *tenderApprovalService) GetTenderApprovalsBySubmissionModeWithTender(ctx var responses []*TenderApprovalWithTenderResponse for _, approval := range tenderApprovals { // Get tender details - tenderResponse, err := s.tenderService.GetByID(ctx, approval.TenderID) + tenderResponse, err := s.tenderService.GetByID(ctx, approval.TenderID, "") if err != nil { s.logger.Warn("Failed to get tender details", map[string]interface{}{ "tender_id": approval.TenderID, @@ -930,7 +930,7 @@ func (s *tenderApprovalService) ListTenderApprovalsWithTender(ctx context.Contex var responses []*TenderApprovalWithTenderResponse for _, approval := range tenderApprovals { // Get tender details - tenderResponse, err := s.tenderService.GetByID(ctx, approval.TenderID) + tenderResponse, err := s.tenderService.GetByID(ctx, approval.TenderID, "") if err != nil { s.logger.Warn("Failed to get tender details", map[string]interface{}{ "tender_id": approval.TenderID,