Implement AI pipeline operations in the admin API

- Added new routes and handlers for AI pipeline operations, including scraping documents, batch summarization, translation, and syncing with the Opplens AI service.
- Introduced request forms for handling tender references and batch operations.
- Enhanced the AI service with methods for triggering batch operations and managing pipeline runs.
- Updated Swagger documentation to reflect the new AI pipeline endpoints and their functionalities.

This update integrates comprehensive AI pipeline capabilities into the tender management system, improving operational efficiency and user experience.
This commit is contained in:
Mazyar
2026-06-14 12:54:13 +03:30
parent a825c4a271
commit dcf19b91cd
12 changed files with 1460 additions and 56 deletions
+322 -28
View File
@@ -125,50 +125,349 @@ func (c *Client) FetchTranslationOnDemand(ctx context.Context, reqBody Translate
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}
// TriggerTranslateBatch calls POST /ai/translate/batch to enqueue background translation.
func (c *Client) TriggerTranslateBatch(ctx context.Context, tenders []TenderRef, languages []string) (*BatchAcceptedResponse, error) {
reqBody := TranslateBatchRequest{
Tenders: tenders,
Languages: languages,
}
return c.postBatchAccepted(ctx, "/ai/translate/batch", reqBody, "translate batch", map[string]interface{}{
"tender_count": len(tenders),
"languages": languages,
})
}
// TriggerSummarizeBatch calls POST /ai/summarize/batch to enqueue background summarization.
func (c *Client) TriggerSummarizeBatch(ctx context.Context, tenders []TenderRef) (*BatchAcceptedResponse, error) {
reqBody := SummarizeBatchRequest{Tenders: tenders}
return c.postBatchAccepted(ctx, "/ai/summarize/batch", reqBody, "summarize batch", map[string]interface{}{
"tender_count": len(tenders),
})
}
// ScrapeDocuments calls POST /scrape/documents for one tender (synchronous, cached).
func (c *Client) ScrapeDocuments(ctx context.Context, reqBody ScrapeDocumentsRequest) (*ScrapeDocumentsResponse, error) {
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to marshal pipeline translate request: %w", err)
return nil, fmt.Errorf("ai_summarizer: failed to marshal scrape request: %w", err)
}
url := c.config.APIBaseURL + "/pipeline/translate"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
url := c.config.APIBaseURL + "/scrape/documents"
httpResp, bodyBytes, err := c.doRawPost(ctx, url, 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)
return nil, 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 == 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 PipelineTranslateResponse
var result ScrapeDocumentsResponse
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode pipeline translate response: %w", err)
return nil, fmt.Errorf("ai_summarizer: failed to decode scrape response: %w", err)
}
c.logger.Info("Pipeline translate triggered", map[string]interface{}{
"status": result.Status,
"languages": result.Languages,
c.logger.Info("AI scrape documents request succeeded", map[string]interface{}{
"contract_folder_id": reqBody.ContractFolderID,
"notice_publication_id": reqBody.NoticePublicationID,
"files_downloaded": result.FilesDownloaded,
})
return &result, nil
}
// ScrapeDocumentsBatch calls POST /scrape/documents/batch.
func (c *Client) ScrapeDocumentsBatch(ctx context.Context, tenders []TenderRef) (*BatchAcceptedResponse, error) {
reqBody := ScrapeDocumentsBatchRequest{Tenders: tenders}
return c.postBatchAccepted(ctx, "/scrape/documents/batch", reqBody, "scrape batch", map[string]interface{}{
"tender_count": len(tenders),
})
}
// PipelineSync calls POST /pipeline/sync to pull the tender list from the backend.
func (c *Client) PipelineSync(ctx context.Context) (*PipelineSyncResponse, error) {
url := c.config.APIBaseURL + "/pipeline/sync"
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 PipelineSyncResponse
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode pipeline sync response: %w", err)
}
}
c.logger.Info("AI pipeline sync completed", map[string]interface{}{
"stored": result.Stored,
})
return &result, nil
}
// PipelineRun calls POST /pipeline/run for one tender (scrape → translate → summarize).
func (c *Client) PipelineRun(ctx context.Context, reqBody PipelineRunRequest) (map[string]interface{}, error) {
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to marshal pipeline run request: %w", err)
}
url := c.config.APIBaseURL + "/pipeline/run"
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 map[string]interface{}
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode pipeline run response: %w", err)
}
}
c.logger.Info("AI pipeline run completed", map[string]interface{}{
"contract_folder_id": reqBody.ContractFolderID,
"notice_publication_id": reqBody.NoticePublicationID,
"languages": reqBody.Languages,
})
return result, nil
}
// PipelineRunBatch calls POST /pipeline/run/batch.
func (c *Client) PipelineRunBatch(ctx context.Context, tenders []TenderRef, languages []string) (*BatchAcceptedResponse, error) {
reqBody := PipelineRunBatchRequest{
Tenders: tenders,
Languages: languages,
}
return c.postBatchAccepted(ctx, "/pipeline/run/batch", reqBody, "pipeline run batch", map[string]interface{}{
"tender_count": len(tenders),
"languages": languages,
})
}
// PipelineAnalyze calls POST /pipeline/analyze to trigger analysis across stored tenders.
func (c *Client) PipelineAnalyze(ctx context.Context) (*PipelineActionResponse, error) {
return c.triggerPipelineAction(ctx, "/pipeline/analyze", "pipeline analyze")
}
// PipelineDailyRun calls POST /pipeline/daily-run (sync + full pipeline on newly synced tenders).
func (c *Client) PipelineDailyRun(ctx context.Context) (*PipelineStartedResponse, error) {
return c.triggerPipelineStarted(ctx, "/pipeline/daily-run", "pipeline daily-run")
}
// PipelineAuto calls POST /pipeline/auto (sync + complete all missing work, idempotent).
func (c *Client) PipelineAuto(ctx context.Context) (*PipelineStartedResponse, error) {
return c.triggerPipelineStarted(ctx, "/pipeline/auto", "pipeline auto")
}
// GetPipelineLastRun returns the result of the last daily-run (GET /pipeline/last-run).
func (c *Client) GetPipelineLastRun(ctx context.Context) (*PipelineReportResponse, error) {
return c.getPipelineReport(ctx, "/pipeline/last-run")
}
// GetPipelineLastAutoRun returns the result of the last auto run (GET /pipeline/last-auto-run).
func (c *Client) GetPipelineLastAutoRun(ctx context.Context) (*PipelineReportResponse, error) {
return c.getPipelineReport(ctx, "/pipeline/last-auto-run")
}
// GetPipelineReport calls GET /pipeline/report.
func (c *Client) GetPipelineReport(ctx context.Context, window string) (*PipelineReportResponse, error) {
return c.getPipelineReportWithQuery(ctx, "/pipeline/report", window)
}
// GetPipelineStats calls GET /pipeline/stats.
func (c *Client) GetPipelineStats(ctx context.Context, window string) (*PipelineStatsResponse, error) {
url := c.config.APIBaseURL + "/pipeline/stats"
if window = strings.TrimSpace(window); window != "" {
url += "?window=" + window
}
httpResp, bodyBytes, err := c.doRawGet(ctx, url)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
if httpResp.StatusCode == http.StatusBadRequest {
return nil, fmt.Errorf("%w: status 400, body: %s", ErrAPINonSuccess, string(bodyBytes))
}
if httpResp.StatusCode >= 400 {
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
}
var result PipelineStatsResponse
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode pipeline stats response: %w", err)
}
}
return &result, nil
}
// GetPipelineMinioStats calls GET /pipeline/minio-stats.
func (c *Client) GetPipelineMinioStats(ctx context.Context) (PipelineMinioStatsResponse, error) {
url := c.config.APIBaseURL + "/pipeline/minio-stats"
httpResp, bodyBytes, err := c.doRawGet(ctx, url)
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 PipelineMinioStatsResponse
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode pipeline minio stats response: %w", err)
}
}
return result, nil
}
func (c *Client) postBatchAccepted(ctx context.Context, path string, reqBody any, label string, fields map[string]interface{}) (*BatchAcceptedResponse, error) {
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to marshal %s request: %w", label, err)
}
url := c.config.APIBaseURL + path
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 BatchAcceptedResponse
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)
}
}
logFields := map[string]interface{}{
"path": path,
"status": result.Status,
"count": result.Count,
}
for k, v := range fields {
logFields[k] = v
}
c.logger.Info("AI batch request accepted", logFields)
return &result, nil
}
func (c *Client) triggerPipelineStarted(ctx context.Context, path, label string) (*PipelineStartedResponse, 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 == http.StatusConflict {
return nil, fmt.Errorf("%w: status 409, body: %s", ErrPipelineJobRunning, string(bodyBytes))
}
if httpResp.StatusCode >= 400 {
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
}
var result PipelineStartedResponse
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 job started", map[string]interface{}{
"path": path,
"status": result.Status,
"report": result.Report,
})
return &result, nil
}
func (c *Client) getPipelineReport(ctx context.Context, path string) (*PipelineReportResponse, error) {
return c.getPipelineReportWithQuery(ctx, path, "")
}
func (c *Client) getPipelineReportWithQuery(ctx context.Context, path, window string) (*PipelineReportResponse, error) {
url := c.config.APIBaseURL + path
if window = strings.TrimSpace(window); window != "" {
url += "?window=" + window
}
httpResp, bodyBytes, err := c.doRawGet(ctx, url)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
if httpResp.StatusCode == http.StatusBadRequest {
return nil, fmt.Errorf("%w: status 400, body: %s", ErrAPINonSuccess, string(bodyBytes))
}
if httpResp.StatusCode >= 400 {
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
}
var result PipelineReportResponse
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode pipeline report response: %w", err)
}
}
return &result, nil
}
func (c *Client) doRawGet(ctx context.Context, url string) (*http.Response, []byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, nil, fmt.Errorf("ai_summarizer: failed to create request: %w", err)
}
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
}
// FetchAnalyzeOnDemand calls POST /ai/analyze for one tender.
func (c *Client) FetchAnalyzeOnDemand(ctx context.Context, reqBody AnalyzeRequest) (*AnalyzeResponse, error) {
jsonBody, err := json.Marshal(reqBody)
@@ -204,11 +503,6 @@ func (c *Client) FetchAnalyzeOnDemand(ctx context.Context, reqBody AnalyzeReques
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)
+113 -7
View File
@@ -4,6 +4,20 @@ import (
"strings"
)
// TenderRef identifies a tender for batch and pipeline requests.
type TenderRef struct {
ContractFolderID string `json:"contract_folder_id"`
NoticePublicationID string `json:"notice_publication_id"`
}
// NewTenderRef builds a TenderRef from contract folder and notice publication ids.
func NewTenderRef(contractFolderID, noticePublicationID string) TenderRef {
return TenderRef{
ContractFolderID: strings.TrimSpace(contractFolderID),
NoticePublicationID: strings.TrimSpace(noticePublicationID),
}
}
// SummarizeRequest is the payload for POST /ai/summarize.
type SummarizeRequest struct {
ContractFolderID string `json:"contract_folder_id"`
@@ -70,9 +84,22 @@ type TranslateRequest struct {
ContractFolderID string `json:"contract_folder_id"`
NoticePublicationID string `json:"notice_publication_id"`
Language string `json:"language"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
RequestSource string `json:"-"` // internal: daily_job | manual_trigger
}
// NewTranslateRequest builds a TranslateRequest for POST /ai/translate.
func NewTranslateRequest(contractFolderID, noticePublicationID, language, title, description string) TranslateRequest {
return TranslateRequest{
ContractFolderID: strings.TrimSpace(contractFolderID),
NoticePublicationID: strings.TrimSpace(noticePublicationID),
Language: strings.ToLower(strings.TrimSpace(language)),
Title: strings.TrimSpace(title),
Description: strings.TrimSpace(description),
}
}
const (
TranslationRequestSourceDailyJob = "daily_job"
TranslationRequestSourceManualTrigger = "manual_trigger"
@@ -122,15 +149,69 @@ type AnalyzeResponse struct {
Model string `json:"model"`
}
// PipelineTranslateRequest represents the request payload for POST /pipeline/translate.
type PipelineTranslateRequest struct {
Languages []string `json:"languages"`
// ScrapeDocumentsRequest is the payload for POST /scrape/documents.
type ScrapeDocumentsRequest struct {
ContractFolderID string `json:"contract_folder_id"`
NoticePublicationID string `json:"notice_publication_id"`
}
// PipelineTranslateResponse represents the response from POST /pipeline/translate.
type PipelineTranslateResponse struct {
Status string `json:"status"`
Languages []string `json:"languages"`
// ScrapeDocumentsFile describes one downloaded document.
type ScrapeDocumentsFile struct {
Filename string `json:"filename"`
Size int64 `json:"size"`
}
// ScrapeDocumentsResponse is returned by POST /scrape/documents.
type ScrapeDocumentsResponse struct {
Success bool `json:"success"`
FilesDownloaded int `json:"files_downloaded"`
Files []ScrapeDocumentsFile `json:"files"`
}
// ScrapeDocumentsBatchRequest is the payload for POST /scrape/documents/batch.
type ScrapeDocumentsBatchRequest struct {
Tenders []TenderRef `json:"tenders"`
}
// SummarizeBatchRequest is the payload for POST /ai/summarize/batch.
type SummarizeBatchRequest struct {
Tenders []TenderRef `json:"tenders"`
}
// TranslateBatchRequest is the payload for POST /ai/translate/batch.
type TranslateBatchRequest struct {
Tenders []TenderRef `json:"tenders"`
Languages []string `json:"languages"`
}
// BatchAcceptedResponse is returned by background batch endpoints (HTTP 202).
type BatchAcceptedResponse struct {
Status string `json:"status"`
Count int `json:"count"`
}
// PipelineSyncResponse is returned by POST /pipeline/sync.
type PipelineSyncResponse struct {
Stored int `json:"stored"`
}
// PipelineRunRequest is the payload for POST /pipeline/run.
type PipelineRunRequest struct {
ContractFolderID string `json:"contract_folder_id"`
NoticePublicationID string `json:"notice_publication_id"`
Languages []string `json:"languages"`
}
// PipelineRunBatchRequest is the payload for POST /pipeline/run/batch.
type PipelineRunBatchRequest struct {
Tenders []TenderRef `json:"tenders"`
Languages []string `json:"languages"`
}
// PipelineStartedResponse is returned by POST /pipeline/daily-run and POST /pipeline/auto.
type PipelineStartedResponse struct {
Status string `json:"status"`
Report string `json:"report"`
}
// PipelineActionResponse is returned by fire-and-forget pipeline endpoints.
@@ -138,6 +219,31 @@ type PipelineActionResponse struct {
Status string `json:"status"`
}
// PipelineReportQuery holds optional query params for GET /pipeline/report and GET /pipeline/stats.
type PipelineReportQuery struct {
Window string `json:"window,omitempty"`
}
// PipelineReportResponse is returned by GET /pipeline/report.
type PipelineReportResponse struct {
Window string `json:"window"`
From string `json:"from"`
To string `json:"to"`
Summary map[string]interface{} `json:"summary,omitempty"`
Days map[string]interface{} `json:"days,omitempty"`
Status string `json:"status,omitempty"`
}
// PipelineStatsResponse is returned by GET /pipeline/stats.
type PipelineStatsResponse struct {
GeneratedAt string `json:"generated_at"`
EventLog map[string]interface{} `json:"event_log"`
Minio map[string]interface{} `json:"minio"`
}
// PipelineMinioStatsResponse is returned by GET /pipeline/minio-stats.
type PipelineMinioStatsResponse map[string]interface{}
// OnboardingRequest is the payload for POST /onboarding.
type OnboardingRequest struct {
CompanyID string `json:"company_id"`
+3
View File
@@ -33,4 +33,7 @@ var (
// ErrAPINonSuccess is returned when the AI API returns a non-2xx status code.
ErrAPINonSuccess = errors.New("ai_summarizer: API returned non-success status")
// ErrPipelineJobRunning is returned when a single-flight pipeline job is already running (HTTP 409).
ErrPipelineJobRunning = errors.New("ai_summarizer: pipeline job already running")
)