9cd22c24b6
- Removed the QueueConfig structure and related queue management files as they are no longer in use. - Updated the worker initialization to reflect the removal of queue-related configurations. - Cleaned up the bootstrap process by eliminating deprecated logging related to the queue system. This update streamlines the worker's configuration and prepares the codebase for future enhancements without the legacy queue management components.
383 lines
12 KiB
Go
383 lines
12 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)
|
|
}
|
|
|
|
// 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
|
|
}
|