Files
tm_back/pkg/ai_summarizer/client.go
T
Mazyar dcf19b91cd Implement AI pipeline operations in the admin API
- Added new routes and handlers for AI pipeline operations, including scraping documents, batch summarization, translation, and syncing with the Opplens AI service.
- Introduced request forms for handling tender references and batch operations.
- Enhanced the AI service with methods for triggering batch operations and managing pipeline runs.
- Updated Swagger documentation to reflect the new AI pipeline endpoints and their functionalities.

This update integrates comprehensive AI pipeline capabilities into the tender management system, improving operational efficiency and user experience.
2026-06-14 12:54:13 +03:30

743 lines
24 KiB
Go

package ai_summarizer
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"tm/pkg/logger"
)
// Client is the HTTP client for the AI summarization on-demand API.
type Client struct {
config *Config
httpClient *http.Client
logger logger.Logger
}
// NewClient creates a new AI summarizer HTTP client.
func NewClient(config *Config, log logger.Logger) (*Client, error) {
if config == nil {
config = DefaultConfig()
}
if err := config.ValidateAPI(); err != nil {
return nil, fmt.Errorf("ai_summarizer: invalid config: %w", err)
}
return &Client{
config: config,
httpClient: &http.Client{
Timeout: config.APITimeout,
},
logger: log,
}, nil
}
// FetchSummaryOnDemand calls the external AI service POST /ai/summarize endpoint
// to generate a summary on demand. It retries on transient failures up to the
// configured number of attempts.
func (c *Client) FetchSummaryOnDemand(ctx context.Context, reqBody SummarizeRequest) (*SummarizeResponse, error) {
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to marshal request body: %w", err)
}
url := c.config.APIBaseURL + "/ai/summarize"
var lastErr error
for attempt := 0; attempt <= c.config.APIRetryCount; attempt++ {
if attempt > 0 {
c.logger.Warn("Retrying AI summarize request", map[string]interface{}{
"contract_folder_id": reqBody.ContractFolderID,
"notice_publication_id": reqBody.NoticePublicationID,
"attempt": attempt,
})
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(c.config.APIRetryDelay):
}
}
resp, err := c.doPost(ctx, url, jsonBody)
if err != nil {
lastErr = err
c.logger.Error("AI summarize request failed", map[string]interface{}{
"contract_folder_id": reqBody.ContractFolderID,
"notice_publication_id": reqBody.NoticePublicationID,
"attempt": attempt,
"error": err.Error(),
})
continue
}
return resp, nil
}
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_publication_id": reqBody.NoticePublicationID,
"language": reqBody.Language,
"attempt": attempt,
})
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(c.config.APIRetryDelay):
}
}
resp, err := c.doTranslatePost(ctx, url, jsonBody, reqBody.RequestSource)
if err != nil {
lastErr = err
c.logger.Error("AI translate request failed", map[string]interface{}{
"notice_publication_id": reqBody.NoticePublicationID,
"language": reqBody.Language,
"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)
}
// TriggerTranslateBatch calls POST /ai/translate/batch to enqueue background translation.
func (c *Client) TriggerTranslateBatch(ctx context.Context, tenders []TenderRef, languages []string) (*BatchAcceptedResponse, error) {
reqBody := TranslateBatchRequest{
Tenders: tenders,
Languages: languages,
}
return c.postBatchAccepted(ctx, "/ai/translate/batch", reqBody, "translate batch", map[string]interface{}{
"tender_count": len(tenders),
"languages": languages,
})
}
// TriggerSummarizeBatch calls POST /ai/summarize/batch to enqueue background summarization.
func (c *Client) TriggerSummarizeBatch(ctx context.Context, tenders []TenderRef) (*BatchAcceptedResponse, error) {
reqBody := SummarizeBatchRequest{Tenders: tenders}
return c.postBatchAccepted(ctx, "/ai/summarize/batch", reqBody, "summarize batch", map[string]interface{}{
"tender_count": len(tenders),
})
}
// ScrapeDocuments calls POST /scrape/documents for one tender (synchronous, cached).
func (c *Client) ScrapeDocuments(ctx context.Context, reqBody ScrapeDocumentsRequest) (*ScrapeDocumentsResponse, error) {
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to marshal scrape request: %w", err)
}
url := c.config.APIBaseURL + "/scrape/documents"
httpResp, bodyBytes, err := c.doRawPost(ctx, url, jsonBody)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
if httpResp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("%w: status 404, body: %s", ErrObjectNotFound, string(bodyBytes))
}
if httpResp.StatusCode >= 400 {
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
}
var result ScrapeDocumentsResponse
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode scrape response: %w", err)
}
c.logger.Info("AI scrape documents request succeeded", map[string]interface{}{
"contract_folder_id": reqBody.ContractFolderID,
"notice_publication_id": reqBody.NoticePublicationID,
"files_downloaded": result.FilesDownloaded,
})
return &result, nil
}
// ScrapeDocumentsBatch calls POST /scrape/documents/batch.
func (c *Client) ScrapeDocumentsBatch(ctx context.Context, tenders []TenderRef) (*BatchAcceptedResponse, error) {
reqBody := ScrapeDocumentsBatchRequest{Tenders: tenders}
return c.postBatchAccepted(ctx, "/scrape/documents/batch", reqBody, "scrape batch", map[string]interface{}{
"tender_count": len(tenders),
})
}
// PipelineSync calls POST /pipeline/sync to pull the tender list from the backend.
func (c *Client) PipelineSync(ctx context.Context) (*PipelineSyncResponse, error) {
url := c.config.APIBaseURL + "/pipeline/sync"
httpResp, bodyBytes, err := c.doRawPost(ctx, url, nil)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
if httpResp.StatusCode >= 400 {
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
}
var result PipelineSyncResponse
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode pipeline sync response: %w", err)
}
}
c.logger.Info("AI pipeline sync completed", map[string]interface{}{
"stored": result.Stored,
})
return &result, nil
}
// PipelineRun calls POST /pipeline/run for one tender (scrape → translate → summarize).
func (c *Client) PipelineRun(ctx context.Context, reqBody PipelineRunRequest) (map[string]interface{}, error) {
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to marshal pipeline run request: %w", err)
}
url := c.config.APIBaseURL + "/pipeline/run"
httpResp, bodyBytes, err := c.doRawPost(ctx, url, jsonBody)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
if httpResp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("%w: status 404, body: %s", ErrObjectNotFound, string(bodyBytes))
}
if httpResp.StatusCode >= 400 {
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
}
var result map[string]interface{}
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode pipeline run response: %w", err)
}
}
c.logger.Info("AI pipeline run completed", map[string]interface{}{
"contract_folder_id": reqBody.ContractFolderID,
"notice_publication_id": reqBody.NoticePublicationID,
"languages": reqBody.Languages,
})
return result, nil
}
// PipelineRunBatch calls POST /pipeline/run/batch.
func (c *Client) PipelineRunBatch(ctx context.Context, tenders []TenderRef, languages []string) (*BatchAcceptedResponse, error) {
reqBody := PipelineRunBatchRequest{
Tenders: tenders,
Languages: languages,
}
return c.postBatchAccepted(ctx, "/pipeline/run/batch", reqBody, "pipeline run batch", map[string]interface{}{
"tender_count": len(tenders),
"languages": languages,
})
}
// PipelineAnalyze calls POST /pipeline/analyze to trigger analysis across stored tenders.
func (c *Client) PipelineAnalyze(ctx context.Context) (*PipelineActionResponse, error) {
return c.triggerPipelineAction(ctx, "/pipeline/analyze", "pipeline analyze")
}
// PipelineDailyRun calls POST /pipeline/daily-run (sync + full pipeline on newly synced tenders).
func (c *Client) PipelineDailyRun(ctx context.Context) (*PipelineStartedResponse, error) {
return c.triggerPipelineStarted(ctx, "/pipeline/daily-run", "pipeline daily-run")
}
// PipelineAuto calls POST /pipeline/auto (sync + complete all missing work, idempotent).
func (c *Client) PipelineAuto(ctx context.Context) (*PipelineStartedResponse, error) {
return c.triggerPipelineStarted(ctx, "/pipeline/auto", "pipeline auto")
}
// GetPipelineLastRun returns the result of the last daily-run (GET /pipeline/last-run).
func (c *Client) GetPipelineLastRun(ctx context.Context) (*PipelineReportResponse, error) {
return c.getPipelineReport(ctx, "/pipeline/last-run")
}
// GetPipelineLastAutoRun returns the result of the last auto run (GET /pipeline/last-auto-run).
func (c *Client) GetPipelineLastAutoRun(ctx context.Context) (*PipelineReportResponse, error) {
return c.getPipelineReport(ctx, "/pipeline/last-auto-run")
}
// GetPipelineReport calls GET /pipeline/report.
func (c *Client) GetPipelineReport(ctx context.Context, window string) (*PipelineReportResponse, error) {
return c.getPipelineReportWithQuery(ctx, "/pipeline/report", window)
}
// GetPipelineStats calls GET /pipeline/stats.
func (c *Client) GetPipelineStats(ctx context.Context, window string) (*PipelineStatsResponse, error) {
url := c.config.APIBaseURL + "/pipeline/stats"
if window = strings.TrimSpace(window); window != "" {
url += "?window=" + window
}
httpResp, bodyBytes, err := c.doRawGet(ctx, url)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
if httpResp.StatusCode == http.StatusBadRequest {
return nil, fmt.Errorf("%w: status 400, body: %s", ErrAPINonSuccess, string(bodyBytes))
}
if httpResp.StatusCode >= 400 {
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
}
var result PipelineStatsResponse
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode pipeline stats response: %w", err)
}
}
return &result, nil
}
// GetPipelineMinioStats calls GET /pipeline/minio-stats.
func (c *Client) GetPipelineMinioStats(ctx context.Context) (PipelineMinioStatsResponse, error) {
url := c.config.APIBaseURL + "/pipeline/minio-stats"
httpResp, bodyBytes, err := c.doRawGet(ctx, url)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
if httpResp.StatusCode >= 400 {
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
}
var result PipelineMinioStatsResponse
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode pipeline minio stats response: %w", err)
}
}
return result, nil
}
func (c *Client) postBatchAccepted(ctx context.Context, path string, reqBody any, label string, fields map[string]interface{}) (*BatchAcceptedResponse, error) {
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to marshal %s request: %w", label, err)
}
url := c.config.APIBaseURL + path
httpResp, bodyBytes, err := c.doRawPost(ctx, url, jsonBody)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
if httpResp.StatusCode >= 400 {
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
}
var result BatchAcceptedResponse
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode %s response: %w", label, err)
}
}
logFields := map[string]interface{}{
"path": path,
"status": result.Status,
"count": result.Count,
}
for k, v := range fields {
logFields[k] = v
}
c.logger.Info("AI batch request accepted", logFields)
return &result, nil
}
func (c *Client) triggerPipelineStarted(ctx context.Context, path, label string) (*PipelineStartedResponse, error) {
url := c.config.APIBaseURL + path
httpResp, bodyBytes, err := c.doRawPost(ctx, url, nil)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
if httpResp.StatusCode == http.StatusConflict {
return nil, fmt.Errorf("%w: status 409, body: %s", ErrPipelineJobRunning, string(bodyBytes))
}
if httpResp.StatusCode >= 400 {
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
}
var result PipelineStartedResponse
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode %s response: %w", label, err)
}
}
c.logger.Info("AI pipeline job started", map[string]interface{}{
"path": path,
"status": result.Status,
"report": result.Report,
})
return &result, nil
}
func (c *Client) getPipelineReport(ctx context.Context, path string) (*PipelineReportResponse, error) {
return c.getPipelineReportWithQuery(ctx, path, "")
}
func (c *Client) getPipelineReportWithQuery(ctx context.Context, path, window string) (*PipelineReportResponse, error) {
url := c.config.APIBaseURL + path
if window = strings.TrimSpace(window); window != "" {
url += "?window=" + window
}
httpResp, bodyBytes, err := c.doRawGet(ctx, url)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
if httpResp.StatusCode == http.StatusBadRequest {
return nil, fmt.Errorf("%w: status 400, body: %s", ErrAPINonSuccess, string(bodyBytes))
}
if httpResp.StatusCode >= 400 {
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
}
var result PipelineReportResponse
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode pipeline report response: %w", err)
}
}
return &result, nil
}
func (c *Client) doRawGet(ctx context.Context, url string) (*http.Response, []byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, nil, fmt.Errorf("ai_summarizer: failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := c.httpClient.Do(req)
if err != nil {
return nil, nil, fmt.Errorf("ai_summarizer: request error: %w", err)
}
bodyBytes, err := io.ReadAll(httpResp.Body)
if err != nil {
httpResp.Body.Close()
return nil, nil, fmt.Errorf("ai_summarizer: failed to read response body: %w", err)
}
return httpResp, bodyBytes, nil
}
// FetchAnalyzeOnDemand calls POST /ai/analyze for one tender.
func (c *Client) FetchAnalyzeOnDemand(ctx context.Context, reqBody AnalyzeRequest) (*AnalyzeResponse, error) {
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to marshal analyze request body: %w", err)
}
url := c.config.APIBaseURL + "/ai/analyze"
httpResp, bodyBytes, err := c.doRawPost(ctx, url, jsonBody)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
if httpResp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("%w: status 404, body: %s", ErrObjectNotFound, string(bodyBytes))
}
if httpResp.StatusCode >= 400 {
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
}
var result AnalyzeResponse
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode analyze response JSON: %w", err)
}
c.logger.Info("AI analyze request succeeded", map[string]interface{}{
"contract_folder_id": result.ContractFolderID,
"notice_publication_id": result.NoticePublicationID,
"iterations": result.Iterations,
})
return &result, nil
}
// StartOnboarding calls POST /onboarding to start company profile indexing for recommendations.
func (c *Client) StartOnboarding(ctx context.Context, reqBody OnboardingRequest) (*OnboardingResponse, error) {
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to marshal onboarding request body: %w", err)
}
url := c.config.APIBaseURL + "/onboarding"
httpResp, bodyBytes, err := c.doRawPost(ctx, url, jsonBody)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
if httpResp.StatusCode >= 400 {
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
}
var result OnboardingResponse
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode onboarding response: %w", err)
}
}
c.logger.Info("AI onboarding request succeeded", map[string]interface{}{
"company_id": reqBody.CompanyID,
"status": result.Status,
})
return &result, nil
}
// FetchRecommendations calls POST /recommend to retrieve ranked tenders for a company.
func (c *Client) FetchRecommendations(ctx context.Context, reqBody RecommendRequest) ([]RecommendedTender, error) {
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to marshal recommend request body: %w", err)
}
url := c.config.APIBaseURL + "/recommend"
httpResp, bodyBytes, err := c.doRawPost(ctx, url, jsonBody)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
if httpResp.StatusCode >= 400 {
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
}
var result []RecommendedTender
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode recommend response: %w", err)
}
}
c.logger.Info("AI recommend request succeeded", map[string]interface{}{
"company_id": reqBody.CompanyID,
"count": len(result),
})
return result, nil
}
func (c *Client) triggerPipelineAction(ctx context.Context, path, label string) (*PipelineActionResponse, error) {
url := c.config.APIBaseURL + path
httpResp, bodyBytes, err := c.doRawPost(ctx, url, nil)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
if httpResp.StatusCode >= 400 {
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
}
var result PipelineActionResponse
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode %s response: %w", label, err)
}
}
c.logger.Info("AI pipeline action triggered", map[string]interface{}{
"path": path,
"status": result.Status,
})
return &result, nil
}
func (c *Client) doRawPost(ctx context.Context, url string, jsonBody []byte) (*http.Response, []byte, error) {
var body io.Reader
if len(jsonBody) > 0 {
body = bytes.NewReader(jsonBody)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
if err != nil {
return nil, nil, fmt.Errorf("ai_summarizer: failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
httpResp, err := c.httpClient.Do(req)
if err != nil {
return nil, nil, fmt.Errorf("ai_summarizer: request error: %w", err)
}
bodyBytes, err := io.ReadAll(httpResp.Body)
if err != nil {
httpResp.Body.Close()
return nil, nil, fmt.Errorf("ai_summarizer: failed to read response body: %w", err)
}
return httpResp, bodyBytes, nil
}
// 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))
if err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
c.logger.Debug("Sending AI summarize request", map[string]interface{}{
"url": url,
})
httpResp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("ai_summarizer: 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 response body: %w", err)
}
if httpResp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("%w: status 404, body: %s", ErrObjectNotFound, string(bodyBytes))
}
if httpResp.StatusCode >= 400 {
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
}
var result SummarizeResponse
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode response JSON: %w", err)
}
c.logger.Info("AI summarize request succeeded", map[string]interface{}{
"contract_folder_id": result.ContractFolderID,
"notice_publication_id": result.ResolvedNoticePublicationID(),
"summaries_count": len(result.Summaries),
})
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, requestSource string) (*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))
}
result, err := decodeTranslateResponse(bodyBytes)
if err != nil {
return nil, err
}
c.logger.Info("AI translate request succeeded", map[string]interface{}{
"notice_publication_id": result.NoticePublicationID,
"language": result.Language,
})
if c.config.OnSuccessfulTranslation != nil {
if err := c.config.OnSuccessfulTranslation(ctx, requestSource); err != nil {
c.logger.Warn("Failed to increment AI translation success counter", map[string]interface{}{
"notice_publication_id": result.NoticePublicationID,
"language": result.Language,
"request_source": requestSource,
"error": err.Error(),
})
}
}
return result, nil
}
type translateAPIEnvelope struct {
Success bool `json:"success"`
Message string `json:"message"`
Data TranslateResponse `json:"data"`
}
func decodeTranslateResponse(bodyBytes []byte) (*TranslateResponse, error) {
var envelope translateAPIEnvelope
if err := json.Unmarshal(bodyBytes, &envelope); err == nil && envelope.Success && envelope.Data.Language != "" {
return &envelope.Data, nil
}
var direct TranslateResponse
if err := json.Unmarshal(bodyBytes, &direct); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode translation response JSON: %w", err)
}
if strings.TrimSpace(direct.Language) == "" {
return nil, fmt.Errorf("ai_summarizer: translation response missing language")
}
return &direct, nil
}