package ai_summarizer import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "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{}{ "notice_id": reqBody.NoticeID, "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{}{ "notice_id": reqBody.NoticeID, "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_id": reqBody.NoticeID, "attempt": attempt, }) select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(c.config.APIRetryDelay): } } resp, err := c.doTranslatePost(ctx, url, jsonBody) if err != nil { lastErr = err c.logger.Error("AI translate request failed", map[string]interface{}{ "notice_id": reqBody.NoticeID, "attempt": attempt, "error": err.Error(), }) continue } return resp, nil } return nil, fmt.Errorf("ai_summarizer: all %d translation attempts failed, last error: %w", c.config.APIRetryCount+1, lastErr) } // doPost performs a single POST request and parses the response. func (c *Client) doPost(ctx context.Context, url string, jsonBody []byte) (*SummarizeResponse, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody)) 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{}{ "notice_id": result.NoticeID, "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) (*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 }