diff --git a/cmd/web/bootstrap/config.go b/cmd/web/bootstrap/config.go index 90175fe..fca6513 100644 --- a/cmd/web/bootstrap/config.go +++ b/cmd/web/bootstrap/config.go @@ -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:""` diff --git a/cmd/web/main.go b/cmd/web/main.go index cb3acd2..5f773dd 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -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) diff --git a/cmd/web/router/routes.go b/cmd/web/router/routes.go index 212ff81..f7f9de7 100644 --- a/cmd/web/router/routes.go +++ b/cmd/web/router/routes.go @@ -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 diff --git a/internal/tender/entity.go b/internal/tender/entity.go index 238cb8f..bbfc7da 100644 --- a/internal/tender/entity.go +++ b/internal/tender/entity.go @@ -78,18 +78,27 @@ type Tender struct { ProjectName string `bson:"project_name" json:"project_name"` // PBL Project Name (__) // 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 diff --git a/internal/tender/form.go b/internal/tender/form.go index 8ea4a9d..ff6373f 100644 --- a/internal/tender/form.go +++ b/internal/tender/form.go @@ -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"` diff --git a/internal/tender/handler.go b/internal/tender/handler.go index 3253377..be1e80a 100644 --- a/internal/tender/handler.go +++ b/internal/tender/handler.go @@ -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 diff --git a/internal/tender/service.go b/internal/tender/service.go index 70be4ef..481a10d 100644 --- a/internal/tender/service.go +++ b/internal/tender/service.go @@ -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 +} diff --git a/pkg/ai_summarizer/client.go b/pkg/ai_summarizer/client.go index 0cb76b5..9beaed1 100644 --- a/pkg/ai_summarizer/client.go +++ b/pkg/ai_summarizer/client.go @@ -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 +} diff --git a/pkg/ai_summarizer/entities.go b/pkg/ai_summarizer/entities.go index 220c1ed..9dcd4a1 100644 --- a/pkg/ai_summarizer/entities.go +++ b/pkg/ai_summarizer/entities.go @@ -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 /tender.json file // stored in the AI service's MinIO bucket. type TenderJSON struct {