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, apiNonSuccessError(httpResp.StatusCode, 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 } func apiNonSuccessError(statusCode int, bodyBytes []byte) error { return &APIStatusError{ StatusCode: statusCode, Body: string(bodyBytes), } } // 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), }) } // GetScrapePortals calls GET /scrape/portals. func (c *Client) GetScrapePortals(ctx context.Context) (*ScrapePortalsResponse, error) { url := c.config.APIBaseURL + "/scrape/portals" httpResp, bodyBytes, err := c.doRawGet(ctx, url) if err != nil { return nil, err } defer httpResp.Body.Close() if httpResp.StatusCode >= 400 { return nil, apiNonSuccessError(httpResp.StatusCode, bodyBytes) } var result ScrapePortalsResponse if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, &result); err != nil { return nil, fmt.Errorf("ai_summarizer: failed to decode scrape portals response: %w", err) } } return &result, nil } // 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, apiNonSuccessError(httpResp.StatusCode, 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, apiNonSuccessError(httpResp.StatusCode, 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, apiNonSuccessError(http.StatusBadRequest, bodyBytes) } if httpResp.StatusCode >= 400 { return nil, apiNonSuccessError(httpResp.StatusCode, 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, apiNonSuccessError(httpResp.StatusCode, 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, apiNonSuccessError(httpResp.StatusCode, 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, apiNonSuccessError(httpResp.StatusCode, 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, apiNonSuccessError(http.StatusBadRequest, bodyBytes) } if httpResp.StatusCode >= 400 { return nil, apiNonSuccessError(httpResp.StatusCode, 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, apiNonSuccessError(httpResp.StatusCode, 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, apiNonSuccessError(httpResp.StatusCode, 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, apiNonSuccessError(httpResp.StatusCode, 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, apiNonSuccessError(httpResp.StatusCode, 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, apiNonSuccessError(httpResp.StatusCode, 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, apiNonSuccessError(httpResp.StatusCode, 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 }