3b26c4f5e1
- Added new AI onboarding and recommendation endpoints in the company handler for starting onboarding and retrieving ranked tender recommendations. - Introduced `StartAIOnboarding` and `GetAIRecommendations` methods in the company service to handle AI interactions. - Updated the company service constructor to include the AI recommendation client. - Enhanced the AI summarizer client with methods for onboarding and fetching recommendations. - Added response structures for onboarding and recommended tenders in the company form. This update enhances the tender management system by integrating AI capabilities for onboarding and tender recommendations, improving user experience and operational efficiency.
449 lines
14 KiB
Go
449 lines
14 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")
|
|
}
|
|
|
|
// 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, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(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, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(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, 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
|
|
}
|