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.
This commit is contained in:
Mazyar
2026-06-08 18:01:15 +03:30
parent de8de9b5c5
commit 7d383c36c3
11 changed files with 559 additions and 502 deletions
+112
View File
@@ -169,6 +169,118 @@ func (c *Client) TriggerPipelineTranslate(ctx context.Context, languages []strin
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))
+75 -65
View File
@@ -2,58 +2,20 @@ package ai_summarizer
import (
"strings"
"time"
)
// SummarizeRequest represents the request payload sent to the AI service
// POST /ai/summarize endpoint for on-demand summarization.
// SummarizeRequest is the payload for POST /ai/summarize.
type SummarizeRequest struct {
ContractFolderID string `json:"contract_folder_id"`
NoticePublicationID string `json:"notice_publication_id"`
DocumentURL string `json:"document_url,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Country string `json:"country,omitempty"`
EstimatedValue *float64 `json:"estimated_value,omitempty"`
Currency string `json:"currency,omitempty"`
SubmissionDeadline string `json:"submission_deadline,omitempty"`
AuthorityName string `json:"authority_name,omitempty"`
}
// SummarizeRequestInput collects fields used to build SummarizeRequest.
type SummarizeRequestInput struct {
ContractFolderID string
NoticePublicationID string
DocumentURL string
Title string
Description string
Country string
Currency string
SubmissionDeadline int64 // Unix seconds; omitted from request when zero
EstimatedValue float64
AuthorityName string
ContractFolderID string `json:"contract_folder_id"`
NoticePublicationID string `json:"notice_publication_id"`
}
// NewSummarizeRequest builds a SummarizeRequest for POST /ai/summarize.
func NewSummarizeRequest(in SummarizeRequestInput) SummarizeRequest {
req := SummarizeRequest{
ContractFolderID: strings.TrimSpace(in.ContractFolderID),
NoticePublicationID: strings.TrimSpace(in.NoticePublicationID),
DocumentURL: strings.TrimSpace(in.DocumentURL),
Title: in.Title,
Description: in.Description,
Country: in.Country,
Currency: in.Currency,
AuthorityName: strings.TrimSpace(in.AuthorityName),
func NewSummarizeRequest(contractFolderID, noticePublicationID string) SummarizeRequest {
return SummarizeRequest{
ContractFolderID: strings.TrimSpace(contractFolderID),
NoticePublicationID: strings.TrimSpace(noticePublicationID),
}
if in.EstimatedValue > 0 {
v := in.EstimatedValue
req.EstimatedValue = &v
}
if in.SubmissionDeadline > 0 {
req.SubmissionDeadline = time.Unix(in.SubmissionDeadline, 0).UTC().Format(time.RFC3339)
}
return req
}
// SummarizeResponse represents the response from the AI summarization API.
@@ -63,6 +25,8 @@ type SummarizeResponse struct {
NoticeID string `json:"notice_id"` // legacy field name
Summaries []DocumentSummary `json:"summaries"`
OverallSummary *string `json:"overall_summary"`
RollupS float64 `json:"rollup_s"`
TotalS float64 `json:"total_s"`
}
// ResolvedNoticePublicationID returns the notice publication ID from the response.
@@ -82,7 +46,7 @@ type DocumentSummary struct {
DocumentType string `json:"document_type"`
Summary string `json:"summary"`
SummaryModel string `json:"summary_model"`
Error string `json:"error"` // empty string when no error
Error string `json:"error"` // empty when no error
}
// ProcedureDocumentsSummary reports scraped documents stored under one procedure folder in MinIO.
@@ -101,13 +65,10 @@ type StoredDocument struct {
DocumentType string `json:"document_type,omitempty"`
}
// TranslateRequest represents the request payload sent to the AI service
// POST /ai/translate endpoint.
// TranslateRequest is the payload for POST /ai/translate.
type TranslateRequest struct {
ContractFolderID string `json:"contract_folder_id"`
NoticePublicationID string `json:"notice_publication_id"`
Title string `json:"title"`
Description string `json:"description"`
Language string `json:"language"`
RequestSource string `json:"-"` // internal: daily_job | manual_trigger
}
@@ -126,6 +87,41 @@ type TranslateResponse struct {
TranslatedDescription string `json:"translated_description"`
}
// AnalyzeRequest is the payload for POST /ai/analyze.
type AnalyzeRequest struct {
ContractFolderID string `json:"contract_folder_id"`
NoticePublicationID string `json:"notice_publication_id"`
}
// NewAnalyzeRequest builds an AnalyzeRequest for POST /ai/analyze.
func NewAnalyzeRequest(contractFolderID, noticePublicationID string) AnalyzeRequest {
return AnalyzeRequest{
ContractFolderID: strings.TrimSpace(contractFolderID),
NoticePublicationID: strings.TrimSpace(noticePublicationID),
}
}
// AnalyzeDocumentStats describes one document touched during agentic analysis.
type AnalyzeDocumentStats struct {
Filename string `json:"filename"`
SizeBytes int64 `json:"size_bytes"`
TimesRead int `json:"times_read"`
TimesQueried int `json:"times_queried"`
Description string `json:"description"`
}
// AnalyzeResponse is the payload from POST /ai/analyze.
type AnalyzeResponse struct {
ContractFolderID string `json:"contract_folder_id"`
NoticePublicationID string `json:"notice_publication_id"`
Summary string `json:"summary"`
Documents []AnalyzeDocumentStats `json:"documents"`
Scratchpad map[string]interface{} `json:"scratchpad"`
ToolCallsLog []string `json:"tool_calls_log"`
Iterations int `json:"iterations"`
Model string `json:"model"`
}
// PipelineTranslateRequest represents the request payload for POST /pipeline/translate.
type PipelineTranslateRequest struct {
Languages []string `json:"languages"`
@@ -137,6 +133,11 @@ type PipelineTranslateResponse struct {
Languages []string `json:"languages"`
}
// PipelineActionResponse is returned by fire-and-forget pipeline endpoints.
type PipelineActionResponse struct {
Status string `json:"status"`
}
// StoredTranslation is one language entry inside tender.json translations map.
type StoredTranslation struct {
Title string `json:"title"`
@@ -173,6 +174,7 @@ type TenderJSON struct {
Summary string `json:"summary"`
OverallSummary *string `json:"overall_summary"`
OverallSummaryCamel *string `json:"overallSummary"`
Summaries []DocumentSummary `json:"summaries"`
SummarizeStatus SummarizeStatus `json:"summarize_status"`
SummarizeStatusCamel SummarizeStatus `json:"summarizeStatus"`
Translations map[string]StoredTranslation `json:"translations"`
@@ -225,23 +227,31 @@ func (t *TenderJSON) translationsDone() []string {
return t.TranslationStatusCamel.languagesDone()
}
// translationForLanguage returns stored translation text when the pipeline marked
// the language done and non-empty title exists.
// storedTranslationText returns translation text from the translations map when
// title is non-empty. It does not require translation_status.done — on-demand
// writes may persist translations before updating status.
func (t *TenderJSON) storedTranslationText(language string) (StoredTranslation, bool) {
if t == nil || t.Translations == nil {
return StoredTranslation{}, false
}
lang := strings.ToLower(strings.TrimSpace(language))
for key, entry := range t.Translations {
if strings.ToLower(strings.TrimSpace(key)) != lang {
continue
}
if strings.TrimSpace(entry.Title) == "" {
continue
}
return entry, true
}
return StoredTranslation{}, false
}
// translationForLanguage returns stored translation when the pipeline marked the
// language done and non-empty title exists.
func (t *TenderJSON) translationForLanguage(language string) (StoredTranslation, bool) {
if t == nil || !languageInDone(t.translationsDone(), language) {
return StoredTranslation{}, false
}
lang := strings.ToLower(strings.TrimSpace(language))
if t.Translations != nil {
for key, entry := range t.Translations {
if strings.ToLower(strings.TrimSpace(key)) != lang {
continue
}
if strings.TrimSpace(entry.Title) == "" {
continue
}
return entry, true
}
}
return StoredTranslation{}, false
return t.storedTranslationText(language)
}
+43 -7
View File
@@ -326,8 +326,9 @@ func (s *StorageClient) GetSummaryFromStorage(ctx context.Context, contractFolde
return s.getSummaryFromObjectKey(ctx, prefix+"tender.json", contractFolderID, noticePublicationID)
}
// GetTranslationFromStorage reads tender.json and returns the translation for a language
// when translation_status.done includes that language.
// GetTranslationFromStorage reads tender.json and returns the translation for a language.
// It prefers entries marked done in translation_status; if missing, it falls back to
// translations[<lang>] when title is non-empty (on-demand API may lag status updates).
func (s *StorageClient) GetTranslationFromStorage(ctx context.Context, contractFolderID, noticePublicationID, language string) (StoredTranslation, error) {
if strings.TrimSpace(contractFolderID) == "" {
return StoredTranslation{}, fmt.Errorf("ai_summarizer: contractFolderID is required")
@@ -353,14 +354,16 @@ func (s *StorageClient) getTranslationFromObjectKey(ctx context.Context, objectK
return StoredTranslation{}, err
}
if entry, ok := tenderJSON.translationForLanguage(language); ok {
return entry, nil
}
if entry, ok := tenderJSON.storedTranslationText(language); ok {
return entry, nil
}
if !languageInDone(tenderJSON.translationsDone(), language) {
return StoredTranslation{}, ErrTranslationNotReady
}
entry, ok := tenderJSON.translationForLanguage(language)
if !ok {
return StoredTranslation{}, ErrNoTranslation
}
return entry, nil
return StoredTranslation{}, ErrNoTranslation
}
// getSummaryFromObjectKey reads tender.json at objectKey and returns summary text when ready.
@@ -432,6 +435,39 @@ func (s *StorageClient) getSummaryFromObjectKey(ctx context.Context, objectKey,
return summaryText, nil
}
// GetDocumentSummariesFromStorage reads per-document summaries from tender.json
// when summarize_status.done is true.
func (s *StorageClient) GetDocumentSummariesFromStorage(ctx context.Context, contractFolderID, noticePublicationID string) ([]DocumentSummary, error) {
if strings.TrimSpace(contractFolderID) == "" {
return nil, fmt.Errorf("ai_summarizer: contractFolderID is required")
}
if strings.TrimSpace(noticePublicationID) == "" {
return nil, fmt.Errorf("ai_summarizer: noticePublicationID is required")
}
prefix, err := s.resolveNoticeStoragePrefix(ctx, contractFolderID, noticePublicationID)
if err != nil {
return nil, err
}
tenderJSON, err := s.readTenderJSONAtKey(ctx, prefix+"tender.json")
if err != nil {
return nil, err
}
done, _ := tenderJSON.resolved()
if !done {
return nil, ErrSummaryNotReady
}
if len(tenderJSON.Summaries) == 0 {
return []DocumentSummary{}, nil
}
out := make([]DocumentSummary, len(tenderJSON.Summaries))
copy(out, tenderJSON.Summaries)
return out, nil
}
// IsSummaryReady is a convenience method that checks whether the tender.json
// exists and the summarize_status.done flag is true, without returning the
// actual summary text.