Integrated AI translation
This commit is contained in:
+21
-12
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user