ai translation refactor

This commit is contained in:
Mazyar
2026-05-19 21:58:44 +03:30
parent e9ec33f0d4
commit 2d5df8556f
5 changed files with 122 additions and 21 deletions
+2 -1
View File
@@ -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
+4 -2
View File
@@ -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")
}
+107 -9
View File
@@ -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_<contractFolderID>/<noticePublicationID>/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