Files
tm_back/pkg/ai_summarizer/client.go
T
Mazyar 7d383c36c3 Implement AI analysis trigger endpoint and refactor related components
- Added a new endpoint to trigger on-demand agentic analysis for tenders via the AI service.
- Introduced `TriggerAIAnalyze` method in the `TenderHandler` to handle requests and responses for AI analysis.
- Updated the `tenderService` to include `TriggerAIAnalyze` method, which validates input and interacts with the AI summarizer client.
- Enhanced the `AIAnalyzeResponse` and `AIAnalyzeDocument` structures to support the new analysis feature.
- Refactored the `DocumentSummarizationWorker` and `TranslationWorker` to remove deprecated MinIO dependencies, streamlining the AI service interactions.

This update improves the functionality of the tender management system by allowing users to trigger AI analysis on-demand, enhancing the overall user experience and system capabilities.
2026-06-08 18:01:15 +03:30

403 lines
13 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
}
// TriggerPipelineSync calls POST /pipeline/sync.
func (c *Client) TriggerPipelineSync(ctx context.Context) (*PipelineActionResponse, error) {
return c.triggerPipelineAction(ctx, "/pipeline/sync", "pipeline sync")
}
// TriggerPipelineScrape calls POST /pipeline/scrape.
func (c *Client) TriggerPipelineScrape(ctx context.Context) (*PipelineActionResponse, error) {
return c.triggerPipelineAction(ctx, "/pipeline/scrape", "pipeline scrape")
}
// TriggerPipelineSummarize calls POST /pipeline/summarize.
func (c *Client) TriggerPipelineSummarize(ctx context.Context) (*PipelineActionResponse, error) {
return c.triggerPipelineAction(ctx, "/pipeline/summarize", "pipeline summarize")
}
// TriggerPipelineAnalyze calls POST /pipeline/analyze.
func (c *Client) TriggerPipelineAnalyze(ctx context.Context) (*PipelineActionResponse, error) {
return c.triggerPipelineAction(ctx, "/pipeline/analyze", "pipeline analyze")
}
// TriggerPipelineDailyRun calls POST /pipeline/daily-run.
func (c *Client) TriggerPipelineDailyRun(ctx context.Context) (*PipelineActionResponse, error) {
return c.triggerPipelineAction(ctx, "/pipeline/daily-run", "pipeline daily-run")
}
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
}