Integrated AI translation
This commit is contained in:
@@ -93,10 +93,11 @@ type DocumentScraperConfig struct {
|
||||
// AISummarizerConfig holds configuration for the external AI summarizer service
|
||||
type AISummarizerConfig struct {
|
||||
// HTTP API settings
|
||||
APIBaseURL string `env:"AI_SUMMARIZER_API_BASE_URL" envDefault:""`
|
||||
APITimeout time.Duration `env:"AI_SUMMARIZER_API_TIMEOUT" envDefault:"120s"`
|
||||
APIRetryCount int `env:"AI_SUMMARIZER_API_RETRY_COUNT" envDefault:"2"`
|
||||
APIRetryDelay time.Duration `env:"AI_SUMMARIZER_API_RETRY_DELAY" envDefault:"3s"`
|
||||
APIBaseURL string `env:"AI_SUMMARIZER_API_BASE_URL" envDefault:""`
|
||||
APITimeout time.Duration `env:"AI_SUMMARIZER_API_TIMEOUT" envDefault:"120s"`
|
||||
APIRetryCount int `env:"AI_SUMMARIZER_API_RETRY_COUNT" envDefault:"2"`
|
||||
APIRetryDelay time.Duration `env:"AI_SUMMARIZER_API_RETRY_DELAY" envDefault:"3s"`
|
||||
DefaultLanguage string `env:"AI_SUMMARIZER_DEFAULT_LANGUAGE" envDefault:"en"`
|
||||
|
||||
// MinIO storage settings
|
||||
MinioEndpoint string `env:"AI_SUMMARIZER_MINIO_ENDPOINT" envDefault:""`
|
||||
|
||||
+1
-1
@@ -207,7 +207,7 @@ func main() {
|
||||
categoryService := company_category.NewService(categoryRepository, logger)
|
||||
companyService := company.NewService(companyRepository, categoryService, logger)
|
||||
customerService := customer.NewService(customerRepository, logger, customerAuthService, companyService, redisClient, notificationSDK, customerValidator, glmSDK)
|
||||
tenderService := tender.NewService(tenderRepository, companyService, customerService, logger, aiSummarizerClient, aiSummarizerStorage)
|
||||
tenderService := tender.NewService(tenderRepository, companyService, customerService, logger, aiSummarizerClient, aiSummarizerStorage, conf.AISummarizer.DefaultLanguage)
|
||||
feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger)
|
||||
tenderApprovalService := tender_approval.NewService(tenderApprovalRepository, tenderService, logger)
|
||||
inquiryService := inquiry.NewService(inquiryRepository, notificationSDK, logger, xssPolicy)
|
||||
|
||||
@@ -111,6 +111,7 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler
|
||||
tendersGP.DELETE("/:id", tenderHandler.Delete)
|
||||
tendersGP.GET("/:id/ai-summary", tenderHandler.GetAISummary)
|
||||
tendersGP.POST("/:id/ai-summarize", tenderHandler.TriggerAISummarize)
|
||||
tendersGP.POST("/:id/ai-translate", tenderHandler.TriggerAITranslate)
|
||||
}
|
||||
|
||||
// Admin Feedback Routes
|
||||
@@ -237,6 +238,7 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tende
|
||||
tendersGP.GET("/details/:id", tenderHandler.GetPublicTenderDetails)
|
||||
tendersGP.GET("/:id/document-summaries", tenderHandler.GetDocumentSummaries)
|
||||
tendersGP.GET("/:id/ai-summary", tenderHandler.GetPublicAISummary)
|
||||
tendersGP.POST("/:id/ai-translate", tenderHandler.TriggerPublicAITranslate)
|
||||
}
|
||||
|
||||
// Public Feedback Routes
|
||||
|
||||
+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
|
||||
}
|
||||
|
||||
@@ -79,6 +79,47 @@ func (c *Client) FetchSummaryOnDemand(ctx context.Context, reqBody SummarizeRequ
|
||||
return nil, fmt.Errorf("ai_summarizer: all %d attempts failed, last error: %w", c.config.APIRetryCount+1, lastErr)
|
||||
}
|
||||
|
||||
// FetchTranslationOnDemand calls the external AI service POST /ai/translate endpoint
|
||||
// to generate title/description translation on demand.
|
||||
func (c *Client) FetchTranslationOnDemand(ctx context.Context, reqBody TranslateRequest) (*TranslateResponse, error) {
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ai_summarizer: failed to marshal translation request body: %w", err)
|
||||
}
|
||||
|
||||
url := c.config.APIBaseURL + "/ai/translate"
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= c.config.APIRetryCount; attempt++ {
|
||||
if attempt > 0 {
|
||||
c.logger.Warn("Retrying AI translate request", map[string]interface{}{
|
||||
"notice_id": reqBody.NoticeID,
|
||||
"attempt": attempt,
|
||||
})
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(c.config.APIRetryDelay):
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.doTranslatePost(ctx, url, jsonBody)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
c.logger.Error("AI translate request failed", map[string]interface{}{
|
||||
"notice_id": reqBody.NoticeID,
|
||||
"attempt": attempt,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("ai_summarizer: all %d translation attempts failed, last error: %w", c.config.APIRetryCount+1, lastErr)
|
||||
}
|
||||
|
||||
// doPost performs a single POST request and parses the response.
|
||||
func (c *Client) doPost(ctx context.Context, url string, jsonBody []byte) (*SummarizeResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
|
||||
@@ -123,3 +164,44 @@ func (c *Client) doPost(ctx context.Context, url string, jsonBody []byte) (*Summ
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// doTranslatePost performs a single translation POST request and parses the response.
|
||||
func (c *Client) doTranslatePost(ctx context.Context, url string, jsonBody []byte) (*TranslateResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ai_summarizer: failed to create translation request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
c.logger.Debug("Sending AI translate request", map[string]interface{}{
|
||||
"url": url,
|
||||
})
|
||||
|
||||
httpResp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ai_summarizer: translation request error: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(httpResp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ai_summarizer: failed to read translation response body: %w", err)
|
||||
}
|
||||
|
||||
if httpResp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result TranslateResponse
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return nil, fmt.Errorf("ai_summarizer: failed to decode translation response JSON: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("AI translate request succeeded", map[string]interface{}{
|
||||
"notice_id": result.NoticeID,
|
||||
"language": result.Language,
|
||||
})
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
@@ -30,6 +30,23 @@ type DocumentSummary struct {
|
||||
Error string `json:"error"` // empty string when no error
|
||||
}
|
||||
|
||||
// TranslateRequest represents the request payload sent to the AI service
|
||||
// POST /ai/translate endpoint.
|
||||
type TranslateRequest struct {
|
||||
NoticeID string `json:"notice_id"` // required
|
||||
Title string `json:"title"` // required
|
||||
Description string `json:"description"` // required
|
||||
Language string `json:"language,omitempty"` // optional target language override
|
||||
}
|
||||
|
||||
// TranslateResponse represents the response from the AI translation API.
|
||||
type TranslateResponse struct {
|
||||
NoticeID string `json:"notice_id"`
|
||||
TranslatedTitle string `json:"translated_title"`
|
||||
TranslatedDescription string `json:"translated_description"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
// TenderJSON represents the structure of the <notice_id>/tender.json file
|
||||
// stored in the AI service's MinIO bucket.
|
||||
type TenderJSON struct {
|
||||
|
||||
Reference in New Issue
Block a user