Integrated AI translation
This commit is contained in:
@@ -93,10 +93,11 @@ type DocumentScraperConfig struct {
|
|||||||
// AISummarizerConfig holds configuration for the external AI summarizer service
|
// AISummarizerConfig holds configuration for the external AI summarizer service
|
||||||
type AISummarizerConfig struct {
|
type AISummarizerConfig struct {
|
||||||
// HTTP API settings
|
// HTTP API settings
|
||||||
APIBaseURL string `env:"AI_SUMMARIZER_API_BASE_URL" envDefault:""`
|
APIBaseURL string `env:"AI_SUMMARIZER_API_BASE_URL" envDefault:""`
|
||||||
APITimeout time.Duration `env:"AI_SUMMARIZER_API_TIMEOUT" envDefault:"120s"`
|
APITimeout time.Duration `env:"AI_SUMMARIZER_API_TIMEOUT" envDefault:"120s"`
|
||||||
APIRetryCount int `env:"AI_SUMMARIZER_API_RETRY_COUNT" envDefault:"2"`
|
APIRetryCount int `env:"AI_SUMMARIZER_API_RETRY_COUNT" envDefault:"2"`
|
||||||
APIRetryDelay time.Duration `env:"AI_SUMMARIZER_API_RETRY_DELAY" envDefault:"3s"`
|
APIRetryDelay time.Duration `env:"AI_SUMMARIZER_API_RETRY_DELAY" envDefault:"3s"`
|
||||||
|
DefaultLanguage string `env:"AI_SUMMARIZER_DEFAULT_LANGUAGE" envDefault:"en"`
|
||||||
|
|
||||||
// MinIO storage settings
|
// MinIO storage settings
|
||||||
MinioEndpoint string `env:"AI_SUMMARIZER_MINIO_ENDPOINT" envDefault:""`
|
MinioEndpoint string `env:"AI_SUMMARIZER_MINIO_ENDPOINT" envDefault:""`
|
||||||
|
|||||||
+1
-1
@@ -207,7 +207,7 @@ func main() {
|
|||||||
categoryService := company_category.NewService(categoryRepository, logger)
|
categoryService := company_category.NewService(categoryRepository, logger)
|
||||||
companyService := company.NewService(companyRepository, categoryService, logger)
|
companyService := company.NewService(companyRepository, categoryService, logger)
|
||||||
customerService := customer.NewService(customerRepository, logger, customerAuthService, companyService, redisClient, notificationSDK, customerValidator, glmSDK)
|
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)
|
feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger)
|
||||||
tenderApprovalService := tender_approval.NewService(tenderApprovalRepository, tenderService, logger)
|
tenderApprovalService := tender_approval.NewService(tenderApprovalRepository, tenderService, logger)
|
||||||
inquiryService := inquiry.NewService(inquiryRepository, notificationSDK, logger, xssPolicy)
|
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.DELETE("/:id", tenderHandler.Delete)
|
||||||
tendersGP.GET("/:id/ai-summary", tenderHandler.GetAISummary)
|
tendersGP.GET("/:id/ai-summary", tenderHandler.GetAISummary)
|
||||||
tendersGP.POST("/:id/ai-summarize", tenderHandler.TriggerAISummarize)
|
tendersGP.POST("/:id/ai-summarize", tenderHandler.TriggerAISummarize)
|
||||||
|
tendersGP.POST("/:id/ai-translate", tenderHandler.TriggerAITranslate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin Feedback Routes
|
// Admin Feedback Routes
|
||||||
@@ -237,6 +238,7 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tende
|
|||||||
tendersGP.GET("/details/:id", tenderHandler.GetPublicTenderDetails)
|
tendersGP.GET("/details/:id", tenderHandler.GetPublicTenderDetails)
|
||||||
tendersGP.GET("/:id/document-summaries", tenderHandler.GetDocumentSummaries)
|
tendersGP.GET("/:id/document-summaries", tenderHandler.GetDocumentSummaries)
|
||||||
tendersGP.GET("/:id/ai-summary", tenderHandler.GetPublicAISummary)
|
tendersGP.GET("/:id/ai-summary", tenderHandler.GetPublicAISummary)
|
||||||
|
tendersGP.POST("/:id/ai-translate", tenderHandler.TriggerPublicAITranslate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public Feedback Routes
|
// 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>)
|
ProjectName string `bson:"project_name" json:"project_name"` // PBL Project Name (<InternalProjectNumber>_<Client-OpportunityName>_<PartnerCode>)
|
||||||
|
|
||||||
// Status-specific fields
|
// Status-specific fields
|
||||||
CancellationReason string `bson:"cancellation_reason,omitempty" json:"cancellation_reason,omitempty"`
|
CancellationReason string `bson:"cancellation_reason,omitempty" json:"cancellation_reason,omitempty"`
|
||||||
CancellationDate int64 `bson:"cancellation_date,omitempty" json:"cancellation_date,omitempty"` // Unix milliseconds
|
CancellationDate int64 `bson:"cancellation_date,omitempty" json:"cancellation_date,omitempty"` // Unix milliseconds
|
||||||
AwardDate int64 `bson:"award_date,omitempty" json:"award_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"`
|
AwardedValue float64 `bson:"awarded_value,omitempty" json:"awarded_value,omitempty"`
|
||||||
WinningTenderer *Organization `bson:"winning_tenderer,omitempty" json:"winning_tenderer,omitempty"`
|
WinningTenderer *Organization `bson:"winning_tenderer,omitempty" json:"winning_tenderer,omitempty"`
|
||||||
ContractNumber string `bson:"contract_number,omitempty" json:"contract_number,omitempty"`
|
ContractNumber string `bson:"contract_number,omitempty" json:"contract_number,omitempty"`
|
||||||
SuspensionReason string `bson:"suspension_reason,omitempty" json:"suspension_reason,omitempty"`
|
SuspensionReason string `bson:"suspension_reason,omitempty" json:"suspension_reason,omitempty"`
|
||||||
SuspensionDate int64 `bson:"suspension_date,omitempty" json:"suspension_date,omitempty"` // Unix milliseconds
|
SuspensionDate int64 `bson:"suspension_date,omitempty" json:"suspension_date,omitempty"` // Unix milliseconds
|
||||||
Modifications []TenderModification `bson:"modifications,omitempty" json:"modifications,omitempty"`
|
Modifications []TenderModification `bson:"modifications,omitempty" json:"modifications,omitempty"`
|
||||||
AwardedEntities []Awarded `bson:"awarded,omitempty" json:"awarded,omitempty"`
|
AwardedEntities []Awarded `bson:"awarded,omitempty" json:"awarded,omitempty"`
|
||||||
ScrapedDocuments []ScrapedDocument `bson:"scraped_documents,omitempty" json:"scraped_documents,omitempty"`
|
ScrapedDocuments []ScrapedDocument `bson:"scraped_documents,omitempty" json:"scraped_documents,omitempty"`
|
||||||
DocumentSummaries []DocumentSummary `bson:"document_summaries,omitempty" json:"document_summaries,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
|
// Organization represents organization information
|
||||||
|
|||||||
+35
-2
@@ -35,6 +35,7 @@ type SearchForm struct {
|
|||||||
BuyerOrganizationID *string `query:"buyer_organization_id" valid:"optional"`
|
BuyerOrganizationID *string `query:"buyer_organization_id" valid:"optional"`
|
||||||
Languages []string `query:"languages" valid:"optional"`
|
Languages []string `query:"languages" valid:"optional"`
|
||||||
OnlyActiveDeadlines bool `query:"-" valid:"optional"`
|
OnlyActiveDeadlines bool `query:"-" valid:"optional"`
|
||||||
|
Language *string `query:"lang" valid:"optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchResponse represents the response for listing tenders
|
// SearchResponse represents the response for listing tenders
|
||||||
@@ -66,6 +67,7 @@ type TenderResponse struct {
|
|||||||
TenderID string `json:"tender_id"`
|
TenderID string `json:"tender_id"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
OverallSummary string `json:"overall_summary,omitempty"` // AI-generated summary from the pipeline
|
OverallSummary string `json:"overall_summary,omitempty"` // AI-generated summary from the pipeline
|
||||||
|
Language string `json:"language,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OrganizationResponse struct {
|
type OrganizationResponse struct {
|
||||||
@@ -74,16 +76,33 @@ type OrganizationResponse struct {
|
|||||||
|
|
||||||
// ToTenderResponse converts a Tender entity to TenderResponse
|
// ToTenderResponse converts a Tender entity to TenderResponse
|
||||||
func (t *Tender) ToResponse() *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{}
|
org := &OrganizationResponse{}
|
||||||
if t.BuyerOrganization != nil {
|
if t.BuyerOrganization != nil {
|
||||||
org.Name = t.BuyerOrganization.Name
|
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{
|
response := &TenderResponse{
|
||||||
ID: t.ID.Hex(),
|
ID: t.ID.Hex(),
|
||||||
NoticePublicationID: t.NoticePublicationID,
|
NoticePublicationID: t.NoticePublicationID,
|
||||||
Title: t.Title,
|
Title: title,
|
||||||
Description: t.Description,
|
Description: description,
|
||||||
ProcurementTypeCode: t.ProcurementTypeCode,
|
ProcurementTypeCode: t.ProcurementTypeCode,
|
||||||
ProcedureCode: t.ProcedureCode,
|
ProcedureCode: t.ProcedureCode,
|
||||||
EstimatedValue: t.EstimatedValue,
|
EstimatedValue: t.EstimatedValue,
|
||||||
@@ -100,11 +119,25 @@ func (t *Tender) ToResponse() *TenderResponse {
|
|||||||
Status: t.Status,
|
Status: t.Status,
|
||||||
TenderID: t.TenderID,
|
TenderID: t.TenderID,
|
||||||
CreatedAt: t.CreatedAt,
|
CreatedAt: t.CreatedAt,
|
||||||
|
Language: usedLanguage,
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
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
|
// AISummaryResponse represents the response for getting an AI-generated summary
|
||||||
type AISummaryResponse struct {
|
type AISummaryResponse struct {
|
||||||
NoticeID string `json:"notice_id"`
|
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")
|
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)
|
// GetPublicAISummary retrieves the AI-generated overall summary for a tender (public endpoint)
|
||||||
// @Summary Get AI-generated tender summary (public)
|
// @Summary Get AI-generated tender summary (public)
|
||||||
// @Description Retrieve the AI-generated overall summary for a tender from the AI pipeline storage
|
// @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")
|
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
|
// GetAllAISummaries retrieves all AI-generated summaries from storage
|
||||||
// @Summary Get all AI-generated tender summaries
|
// @Summary Get all AI-generated tender summaries
|
||||||
// @Description Retrieve all AI-generated summaries from the AI pipeline storage, including pending and completed ones
|
// @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
|
// AISummarizerClient defines the interface for on-demand AI summarization
|
||||||
type AISummarizerClient interface {
|
type AISummarizerClient interface {
|
||||||
FetchSummaryOnDemand(ctx context.Context, reqBody ai_summarizer.SummarizeRequest) (*ai_summarizer.SummarizeResponse, error)
|
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
|
// AISummarizerStorage defines the interface for fetching summaries from MinIO storage
|
||||||
@@ -36,6 +37,7 @@ type Service interface {
|
|||||||
// AI summarization operations
|
// AI summarization operations
|
||||||
GetAISummary(ctx context.Context, id string) (*AISummaryResponse, error)
|
GetAISummary(ctx context.Context, id string) (*AISummaryResponse, error)
|
||||||
TriggerAISummarize(ctx context.Context, id string) (*AISummarizeResponse, 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)
|
GetAllAISummaries(ctx context.Context) ([]ai_summarizer.TenderSummaryItem, error)
|
||||||
|
|
||||||
// Maintenance operations
|
// Maintenance operations
|
||||||
@@ -51,10 +53,15 @@ type tenderService struct {
|
|||||||
logger logger.Logger
|
logger logger.Logger
|
||||||
aiSummarizerClient AISummarizerClient
|
aiSummarizerClient AISummarizerClient
|
||||||
aiSummarizerStorage AISummarizerStorage
|
aiSummarizerStorage AISummarizerStorage
|
||||||
|
defaultLanguage string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new tender service
|
// 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{
|
return &tenderService{
|
||||||
repository: repository,
|
repository: repository,
|
||||||
companyService: companyService,
|
companyService: companyService,
|
||||||
@@ -62,6 +69,7 @@ func NewService(repository TenderRepository, companyService company.Service, cus
|
|||||||
logger: logger,
|
logger: logger,
|
||||||
aiSummarizerClient: aiClient,
|
aiSummarizerClient: aiClient,
|
||||||
aiSummarizerStorage: aiStorage,
|
aiSummarizerStorage: aiStorage,
|
||||||
|
defaultLanguage: strings.ToLower(defaultLanguage),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +89,11 @@ func (s *tenderService) GetByID(ctx context.Context, id string) (*TenderResponse
|
|||||||
}
|
}
|
||||||
|
|
||||||
resp := tender.ToResponse()
|
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)
|
// Enrich with AI summary from MinIO storage (best-effort, non-blocking)
|
||||||
s.enrichWithAISummary(ctx, resp)
|
s.enrichWithAISummary(ctx, resp)
|
||||||
@@ -126,11 +139,14 @@ func (s *tenderService) Update(ctx context.Context, req UpdateTenderRequest) (*T
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update fields if provided
|
// Update fields if provided
|
||||||
|
translationSourceChanged := false
|
||||||
if req.Title != nil {
|
if req.Title != nil {
|
||||||
tender.Title = *req.Title
|
tender.Title = *req.Title
|
||||||
|
translationSourceChanged = true
|
||||||
}
|
}
|
||||||
if req.Description != nil {
|
if req.Description != nil {
|
||||||
tender.Description = *req.Description
|
tender.Description = *req.Description
|
||||||
|
translationSourceChanged = true
|
||||||
}
|
}
|
||||||
if req.Status != nil {
|
if req.Status != nil {
|
||||||
tender.Status = *req.Status
|
tender.Status = *req.Status
|
||||||
@@ -144,6 +160,11 @@ func (s *tenderService) Update(ctx context.Context, req UpdateTenderRequest) (*T
|
|||||||
if req.TenderDeadline != nil {
|
if req.TenderDeadline != nil {
|
||||||
tender.TenderDeadline = *req.TenderDeadline
|
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 {
|
if err := s.repository.Update(ctx, tender); err != nil {
|
||||||
s.logger.Error("Failed to update tender", map[string]interface{}{
|
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 == "" {
|
if form.CompanyID == nil || *form.CompanyID == "" {
|
||||||
tenderResponses := make([]TenderResponse, 0, len(tenders))
|
tenderResponses := make([]TenderResponse, 0, len(tenders))
|
||||||
for _, tender := range tenders {
|
for _, tender := range tenders {
|
||||||
tenderResponses = append(tenderResponses, *tender.ToResponse())
|
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return &SearchResponse{
|
return &SearchResponse{
|
||||||
@@ -283,7 +304,7 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
|
|||||||
// Continue without match percentage if company not found
|
// Continue without match percentage if company not found
|
||||||
tenderResponses := make([]TenderResponse, 0, len(tenders))
|
tenderResponses := make([]TenderResponse, 0, len(tenders))
|
||||||
for _, tender := range tenders {
|
for _, tender := range tenders {
|
||||||
tenderResponses = append(tenderResponses, *tender.ToResponse())
|
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return &SearchResponse{
|
return &SearchResponse{
|
||||||
@@ -306,7 +327,7 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
|
|||||||
|
|
||||||
tenderResponses := make([]TenderResponse, 0, len(tenders))
|
tenderResponses := make([]TenderResponse, 0, len(tenders))
|
||||||
for _, tender := range tenders {
|
for _, tender := range tenders {
|
||||||
tenderResponses = append(tenderResponses, *tender.ToResponse())
|
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return &SearchResponse{
|
return &SearchResponse{
|
||||||
@@ -373,7 +394,7 @@ func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, paginat
|
|||||||
|
|
||||||
tenderResponses := make([]TenderResponse, 0, len(tenders))
|
tenderResponses := make([]TenderResponse, 0, len(tenders))
|
||||||
for _, tender := range tenders {
|
for _, tender := range tenders {
|
||||||
tenderResponses = append(tenderResponses, *tender.ToResponse())
|
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return &SearchResponse{
|
return &SearchResponse{
|
||||||
@@ -662,3 +683,97 @@ func (s *tenderService) GetAllAISummaries(ctx context.Context) ([]ai_summarizer.
|
|||||||
|
|
||||||
return results, nil
|
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)
|
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.
|
// doPost performs a single POST request and parses the response.
|
||||||
func (c *Client) doPost(ctx context.Context, url string, jsonBody []byte) (*SummarizeResponse, error) {
|
func (c *Client) doPost(ctx context.Context, url string, jsonBody []byte) (*SummarizeResponse, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
|
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
|
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
|
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
|
// TenderJSON represents the structure of the <notice_id>/tender.json file
|
||||||
// stored in the AI service's MinIO bucket.
|
// stored in the AI service's MinIO bucket.
|
||||||
type TenderJSON struct {
|
type TenderJSON struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user