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) } // TriggerPipelineTranslate calls POST /pipeline/translate to enqueue batch translation // for the given target languages. func (c *Client) TriggerPipelineTranslate(ctx context.Context, languages []string) (*PipelineTranslateResponse, error) { reqBody := PipelineTranslateRequest{Languages: languages} jsonBody, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("ai_summarizer: failed to marshal pipeline translate request: %w", err) } url := c.config.APIBaseURL + "/pipeline/translate" req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody)) if err != nil { return nil, fmt.Errorf("ai_summarizer: failed to create pipeline translate 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, fmt.Errorf("ai_summarizer: pipeline translate 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 pipeline translate response: %w", err) } if httpResp.StatusCode >= 400 { return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes)) } var result PipelineTranslateResponse if err := json.Unmarshal(bodyBytes, &result); err != nil { return nil, fmt.Errorf("ai_summarizer: failed to decode pipeline translate response: %w", err) } c.logger.Info("Pipeline translate triggered", map[string]interface{}{ "status": result.Status, "languages": result.Languages, }) return &result, 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 } // TriggerPipelineSummarize calls POST /pipeline/summarize. func (c *Client) TriggerPipelineSummarize(ctx context.Context) (*PipelineActionResponse, error) { return c.triggerPipelineAction(ctx, "/pipeline/summarize", "pipeline summarize") } 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 }