Integrated AI translation

This commit is contained in:
Mazyar
2026-05-08 16:17:33 +03:30
parent 65a19d1514
commit b480ed2371
9 changed files with 357 additions and 24 deletions
+21 -12
View File
@@ -78,18 +78,27 @@ type Tender struct {
ProjectName string `bson:"project_name" json:"project_name"` // PBL Project Name (<InternalProjectNumber>_<Client-OpportunityName>_<PartnerCode>)
// Status-specific fields
CancellationReason string `bson:"cancellation_reason,omitempty" json:"cancellation_reason,omitempty"`
CancellationDate int64 `bson:"cancellation_date,omitempty" json:"cancellation_date,omitempty"` // Unix milliseconds
AwardDate int64 `bson:"award_date,omitempty" json:"award_date,omitempty"` // Unix milliseconds
AwardedValue float64 `bson:"awarded_value,omitempty" json:"awarded_value,omitempty"`
WinningTenderer *Organization `bson:"winning_tenderer,omitempty" json:"winning_tenderer,omitempty"`
ContractNumber string `bson:"contract_number,omitempty" json:"contract_number,omitempty"`
SuspensionReason string `bson:"suspension_reason,omitempty" json:"suspension_reason,omitempty"`
SuspensionDate int64 `bson:"suspension_date,omitempty" json:"suspension_date,omitempty"` // Unix milliseconds
Modifications []TenderModification `bson:"modifications,omitempty" json:"modifications,omitempty"`
AwardedEntities []Awarded `bson:"awarded,omitempty" json:"awarded,omitempty"`
ScrapedDocuments []ScrapedDocument `bson:"scraped_documents,omitempty" json:"scraped_documents,omitempty"`
DocumentSummaries []DocumentSummary `bson:"document_summaries,omitempty" json:"document_summaries,omitempty"`
CancellationReason string `bson:"cancellation_reason,omitempty" json:"cancellation_reason,omitempty"`
CancellationDate int64 `bson:"cancellation_date,omitempty" json:"cancellation_date,omitempty"` // Unix milliseconds
AwardDate int64 `bson:"award_date,omitempty" json:"award_date,omitempty"` // Unix milliseconds
AwardedValue float64 `bson:"awarded_value,omitempty" json:"awarded_value,omitempty"`
WinningTenderer *Organization `bson:"winning_tenderer,omitempty" json:"winning_tenderer,omitempty"`
ContractNumber string `bson:"contract_number,omitempty" json:"contract_number,omitempty"`
SuspensionReason string `bson:"suspension_reason,omitempty" json:"suspension_reason,omitempty"`
SuspensionDate int64 `bson:"suspension_date,omitempty" json:"suspension_date,omitempty"` // Unix milliseconds
Modifications []TenderModification `bson:"modifications,omitempty" json:"modifications,omitempty"`
AwardedEntities []Awarded `bson:"awarded,omitempty" json:"awarded,omitempty"`
ScrapedDocuments []ScrapedDocument `bson:"scraped_documents,omitempty" json:"scraped_documents,omitempty"`
DocumentSummaries []DocumentSummary `bson:"document_summaries,omitempty" json:"document_summaries,omitempty"`
Translations map[string]TranslationEntry `bson:"translations,omitempty" json:"translations,omitempty"`
}
// TranslationEntry stores translated tender content for a target language.
type TranslationEntry struct {
Title string `bson:"title" json:"title"`
Description string `bson:"description" json:"description"`
Language string `bson:"language" json:"language"`
TranslatedAt int64 `bson:"translated_at" json:"translated_at"`
}
// Organization represents organization information
+35 -2
View File
@@ -35,6 +35,7 @@ type SearchForm struct {
BuyerOrganizationID *string `query:"buyer_organization_id" valid:"optional"`
Languages []string `query:"languages" valid:"optional"`
OnlyActiveDeadlines bool `query:"-" valid:"optional"`
Language *string `query:"lang" valid:"optional"`
}
// SearchResponse represents the response for listing tenders
@@ -66,6 +67,7 @@ type TenderResponse struct {
TenderID string `json:"tender_id"`
CreatedAt int64 `json:"created_at"`
OverallSummary string `json:"overall_summary,omitempty"` // AI-generated summary from the pipeline
Language string `json:"language,omitempty"`
}
type OrganizationResponse struct {
@@ -74,16 +76,33 @@ type OrganizationResponse struct {
// ToTenderResponse converts a Tender entity to TenderResponse
func (t *Tender) ToResponse() *TenderResponse {
return t.ToResponseWithLanguage("")
}
// ToResponseWithLanguage converts a Tender entity to TenderResponse using a preferred language.
// When language is provided and translation exists, translated title/description are returned.
func (t *Tender) ToResponseWithLanguage(language string) *TenderResponse {
org := &OrganizationResponse{}
if t.BuyerOrganization != nil {
org.Name = t.BuyerOrganization.Name
}
title := t.Title
description := t.Description
usedLanguage := ""
if language != "" {
if entry, ok := t.Translations[language]; ok {
title = entry.Title
description = entry.Description
usedLanguage = language
}
}
response := &TenderResponse{
ID: t.ID.Hex(),
NoticePublicationID: t.NoticePublicationID,
Title: t.Title,
Description: t.Description,
Title: title,
Description: description,
ProcurementTypeCode: t.ProcurementTypeCode,
ProcedureCode: t.ProcedureCode,
EstimatedValue: t.EstimatedValue,
@@ -100,11 +119,25 @@ func (t *Tender) ToResponse() *TenderResponse {
Status: t.Status,
TenderID: t.TenderID,
CreatedAt: t.CreatedAt,
Language: usedLanguage,
}
return response
}
// AITranslateRequest represents a request for on-demand AI translation.
type AITranslateRequest struct {
Language string `json:"language" valid:"required,stringlength(2|5)~Language must be between 2 and 5 characters"`
}
// AITranslateResponse represents the response for AI translation trigger.
type AITranslateResponse struct {
NoticeID string `json:"notice_id"`
Language string `json:"language"`
TranslatedTitle string `json:"translated_title"`
TranslatedDescription string `json:"translated_description"`
}
// AISummaryResponse represents the response for getting an AI-generated summary
type AISummaryResponse struct {
NoticeID string `json:"notice_id"`
+74
View File
@@ -421,6 +421,43 @@ func (h *TenderHandler) TriggerAISummarize(c echo.Context) error {
return response.Success(c, result, "AI summarization completed successfully")
}
// TriggerAITranslate triggers on-demand AI translation for a tender
// @Summary Trigger AI translation
// @Description Trigger on-demand AI translation for tender title and description and store it in MongoDB
// @Tags Admin-Tenders
// @Accept json
// @Produce json
// @Param id path string true "Tender ID"
// @Param request body AITranslateRequest false "Translation language request (defaults to en)"
// @Success 200 {object} response.APIResponse{data=AITranslateResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/v1/tenders/{id}/ai-translate [post]
// @Security BearerAuth
func (h *TenderHandler) TriggerAITranslate(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
}
req := AITranslateRequest{Language: "en"}
_ = c.Bind(&req)
if req.Language == "" {
req.Language = "en"
}
if _, err := govalidator.ValidateStruct(req); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
result, err := h.service.TriggerAITranslate(c.Request().Context(), id, req.Language)
if err != nil {
return response.InternalServerError(c, err.Error())
}
return response.Success(c, result, "AI translation completed successfully")
}
// GetPublicAISummary retrieves the AI-generated overall summary for a tender (public endpoint)
// @Summary Get AI-generated tender summary (public)
// @Description Retrieve the AI-generated overall summary for a tender from the AI pipeline storage
@@ -451,6 +488,43 @@ func (h *TenderHandler) GetPublicAISummary(c echo.Context) error {
return response.Success(c, summary, "AI summary retrieved successfully")
}
// TriggerPublicAITranslate triggers on-demand AI translation for a tender (public endpoint)
// @Summary Trigger AI translation (public)
// @Description Trigger on-demand AI translation for tender title and description and store it in MongoDB
// @Tags Tenders
// @Accept json
// @Produce json
// @Param id path string true "Tender ID"
// @Param request body AITranslateRequest false "Translation language request (defaults to en)"
// @Success 200 {object} response.APIResponse{data=AITranslateResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /api/v1/tenders/{id}/ai-translate [post]
// @Security BearerAuth
func (h *TenderHandler) TriggerPublicAITranslate(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
}
req := AITranslateRequest{Language: "en"}
_ = c.Bind(&req)
if req.Language == "" {
req.Language = "en"
}
if _, err := govalidator.ValidateStruct(req); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
result, err := h.service.TriggerAITranslate(c.Request().Context(), id, req.Language)
if err != nil {
return response.InternalServerError(c, err.Error())
}
return response.Success(c, result, "AI translation completed successfully")
}
// GetAllAISummaries retrieves all AI-generated summaries from storage
// @Summary Get all AI-generated tender summaries
// @Description Retrieve all AI-generated summaries from the AI pipeline storage, including pending and completed ones
+120 -5
View File
@@ -16,6 +16,7 @@ import (
// AISummarizerClient defines the interface for on-demand AI summarization
type AISummarizerClient interface {
FetchSummaryOnDemand(ctx context.Context, reqBody ai_summarizer.SummarizeRequest) (*ai_summarizer.SummarizeResponse, error)
FetchTranslationOnDemand(ctx context.Context, reqBody ai_summarizer.TranslateRequest) (*ai_summarizer.TranslateResponse, error)
}
// AISummarizerStorage defines the interface for fetching summaries from MinIO storage
@@ -36,6 +37,7 @@ type Service interface {
// AI summarization operations
GetAISummary(ctx context.Context, id string) (*AISummaryResponse, error)
TriggerAISummarize(ctx context.Context, id string) (*AISummarizeResponse, error)
TriggerAITranslate(ctx context.Context, id, language string) (*AITranslateResponse, error)
GetAllAISummaries(ctx context.Context) ([]ai_summarizer.TenderSummaryItem, error)
// Maintenance operations
@@ -51,10 +53,15 @@ type tenderService struct {
logger logger.Logger
aiSummarizerClient AISummarizerClient
aiSummarizerStorage AISummarizerStorage
defaultLanguage string
}
// NewService creates a new tender service
func NewService(repository TenderRepository, companyService company.Service, customerService customer.Service, logger logger.Logger, aiClient AISummarizerClient, aiStorage AISummarizerStorage) Service {
func NewService(repository TenderRepository, companyService company.Service, customerService customer.Service, logger logger.Logger, aiClient AISummarizerClient, aiStorage AISummarizerStorage, defaultLanguage string) Service {
if strings.TrimSpace(defaultLanguage) == "" {
defaultLanguage = "en"
}
return &tenderService{
repository: repository,
companyService: companyService,
@@ -62,6 +69,7 @@ func NewService(repository TenderRepository, companyService company.Service, cus
logger: logger,
aiSummarizerClient: aiClient,
aiSummarizerStorage: aiStorage,
defaultLanguage: strings.ToLower(defaultLanguage),
}
}
@@ -81,6 +89,11 @@ func (s *tenderService) GetByID(ctx context.Context, id string) (*TenderResponse
}
resp := tender.ToResponse()
if tr, ok := tender.Translations[s.defaultLanguage]; ok {
resp.Title = tr.Title
resp.Description = tr.Description
resp.Language = s.defaultLanguage
}
// Enrich with AI summary from MinIO storage (best-effort, non-blocking)
s.enrichWithAISummary(ctx, resp)
@@ -126,11 +139,14 @@ func (s *tenderService) Update(ctx context.Context, req UpdateTenderRequest) (*T
}
// Update fields if provided
translationSourceChanged := false
if req.Title != nil {
tender.Title = *req.Title
translationSourceChanged = true
}
if req.Description != nil {
tender.Description = *req.Description
translationSourceChanged = true
}
if req.Status != nil {
tender.Status = *req.Status
@@ -144,6 +160,11 @@ func (s *tenderService) Update(ctx context.Context, req UpdateTenderRequest) (*T
if req.TenderDeadline != nil {
tender.TenderDeadline = *req.TenderDeadline
}
if translationSourceChanged {
tender.Translations = nil
tender.ProcessingMetadata.TranslatedData = nil
tender.ProcessingMetadata.TranslatedAt = 0
}
if err := s.repository.Update(ctx, tender); err != nil {
s.logger.Error("Failed to update tender", map[string]interface{}{
@@ -264,7 +285,7 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
if form.CompanyID == nil || *form.CompanyID == "" {
tenderResponses := make([]TenderResponse, 0, len(tenders))
for _, tender := range tenders {
tenderResponses = append(tenderResponses, *tender.ToResponse())
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
}
return &SearchResponse{
@@ -283,7 +304,7 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
// Continue without match percentage if company not found
tenderResponses := make([]TenderResponse, 0, len(tenders))
for _, tender := range tenders {
tenderResponses = append(tenderResponses, *tender.ToResponse())
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
}
return &SearchResponse{
@@ -306,7 +327,7 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
tenderResponses := make([]TenderResponse, 0, len(tenders))
for _, tender := range tenders {
tenderResponses = append(tenderResponses, *tender.ToResponse())
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
}
return &SearchResponse{
@@ -373,7 +394,7 @@ func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, paginat
tenderResponses := make([]TenderResponse, 0, len(tenders))
for _, tender := range tenders {
tenderResponses = append(tenderResponses, *tender.ToResponse())
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
}
return &SearchResponse{
@@ -662,3 +683,97 @@ func (s *tenderService) GetAllAISummaries(ctx context.Context) ([]ai_summarizer.
return results, nil
}
// TriggerAITranslate triggers on-demand AI translation for a tender and stores
// translated result per language in MongoDB for future reuse.
func (s *tenderService) TriggerAITranslate(ctx context.Context, id, language string) (*AITranslateResponse, error) {
if id == "" {
return nil, fmt.Errorf("tender ID cannot be empty")
}
if s.aiSummarizerClient == nil {
return nil, fmt.Errorf("AI summarizer service is not configured")
}
targetLanguage := strings.ToLower(strings.TrimSpace(language))
if targetLanguage == "" {
targetLanguage = s.defaultLanguage
}
t, err := s.repository.GetByID(ctx, id)
if err != nil {
s.logger.Error("Failed to get tender for AI translation", map[string]interface{}{
"tender_id": id,
"error": err.Error(),
})
return nil, fmt.Errorf("failed to get tender: %w", err)
}
if t == nil {
return nil, fmt.Errorf("tender not found")
}
if t.NoticePublicationID == "" {
return nil, fmt.Errorf("tender has no notice publication ID")
}
if tr, ok := t.Translations[targetLanguage]; ok {
return &AITranslateResponse{
NoticeID: t.NoticePublicationID,
Language: targetLanguage,
TranslatedTitle: tr.Title,
TranslatedDescription: tr.Description,
}, nil
}
resp, err := s.aiSummarizerClient.FetchTranslationOnDemand(ctx, ai_summarizer.TranslateRequest{
NoticeID: t.NoticePublicationID,
Title: t.Title,
Description: t.Description,
Language: targetLanguage,
})
if err != nil {
s.logger.Error("On-demand AI translation failed", map[string]interface{}{
"tender_id": id,
"notice_id": t.NoticePublicationID,
"language": targetLanguage,
"error": err.Error(),
})
return nil, fmt.Errorf("AI translation request failed: %w", err)
}
if t.Translations == nil {
t.Translations = make(map[string]TranslationEntry)
}
now := time.Now().Unix()
t.Translations[targetLanguage] = TranslationEntry{
Title: resp.TranslatedTitle,
Description: resp.TranslatedDescription,
Language: targetLanguage,
TranslatedAt: now,
}
if t.ProcessingMetadata.TranslatedData == nil {
t.ProcessingMetadata.TranslatedData = make(map[string]string)
}
t.ProcessingMetadata.TranslatedData[targetLanguage] = "done"
t.ProcessingMetadata.TranslatedAt = now
if err := s.repository.Update(ctx, t); err != nil {
return nil, fmt.Errorf("failed to persist translation: %w", err)
}
return &AITranslateResponse{
NoticeID: resp.NoticeID,
Language: targetLanguage,
TranslatedTitle: resp.TranslatedTitle,
TranslatedDescription: resp.TranslatedDescription,
}, nil
}
func (s *tenderService) pickResponseLanguage(language *string) string {
if language != nil {
clean := strings.ToLower(strings.TrimSpace(*language))
if clean != "" {
return clean
}
}
return s.defaultLanguage
}