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
+73 -178
View File
@@ -2,35 +2,39 @@ package workers
import (
"context"
"path/filepath"
"strings"
"time"
"tm/internal/tender"
ai_summarizer "tm/pkg/ai_summarizer"
"tm/pkg/logger"
"tm/pkg/minio"
"tm/pkg/mongo"
)
const presignedDocumentURLSeconds int64 = 7200
// DocumentSummarizationWorker asks the external AI service to summarize scraped tender documents.
// DocumentSummarizationWorker triggers AI summarization for scraped tenders.
// The AI service owns MinIO artefacts; this worker marks Mongo processing flags
// after the pipeline or on-demand summarize API completes.
type DocumentSummarizationWorker struct {
Mongo *mongo.ConnectionManager
Logger logger.Logger
TenderRepo tender.TenderRepository
MinioSDK *minio.Service
AIClient *ai_summarizer.Client
AIStorage *ai_summarizer.StorageClient
}
// NewDocumentSummarizationWorker creates a document summarization worker.
func NewDocumentSummarizationWorker(mongo *mongo.ConnectionManager, logger logger.Logger, tenderRepo tender.TenderRepository, minioSDK *minio.Service, aiClient *ai_summarizer.Client) *DocumentSummarizationWorker {
func NewDocumentSummarizationWorker(
mongo *mongo.ConnectionManager,
logger logger.Logger,
tenderRepo tender.TenderRepository,
aiClient *ai_summarizer.Client,
aiStorage *ai_summarizer.StorageClient,
) *DocumentSummarizationWorker {
return &DocumentSummarizationWorker{
Mongo: mongo,
Logger: logger,
TenderRepo: tenderRepo,
MinioSDK: minioSDK,
AIClient: aiClient,
AIStorage: aiStorage,
}
}
@@ -42,9 +46,11 @@ func (w *DocumentSummarizationWorker) Run() {
w.Logger.Warn("AI summarizer client not configured, skipping document summarization run", map[string]interface{}{})
return
}
if w.MinioSDK == nil {
w.Logger.Warn("MinIO not configured, skipping document summarization run", map[string]interface{}{})
return
if _, err := w.AIClient.TriggerPipelineSummarize(context.Background()); err != nil {
w.Logger.Warn("Failed to trigger AI pipeline summarize (continuing with per-tender sync)", map[string]interface{}{
"error": err.Error(),
})
}
limit := 5
@@ -54,7 +60,7 @@ func (w *DocumentSummarizationWorker) Run() {
w.Logger.Error("Failed to get un-summarized tenders", map[string]interface{}{
"error": err.Error(),
})
continue
return
}
w.Logger.Info("Found tenders for document summarization", map[string]interface{}{
@@ -63,7 +69,6 @@ func (w *DocumentSummarizationWorker) Run() {
})
if len(tenders) == 0 {
w.Logger.Info("No more tenders to process for document summarization", map[string]interface{}{})
break
}
@@ -72,78 +77,78 @@ func (w *DocumentSummarizationWorker) Run() {
w.Logger.Info("Processing tender for document summarization", map[string]interface{}{
"tender_id": t.ID.Hex(),
"notice_publication_id": t.NoticePublicationID,
"document_count": len(t.ScrapedDocuments),
})
w.summarizeDocumentsForTender(t)
w.summarizeTender(t)
}
}
w.Logger.Info("Document summarization worker completed", map[string]interface{}{})
}
func (w *DocumentSummarizationWorker) summarizeDocumentsForTender(t *tender.Tender) {
if len(t.ScrapedDocuments) == 0 {
w.Logger.Warn("No scraped documents found for tender", map[string]interface{}{
"tender_id": t.ID.Hex(),
"notice_publication_id": t.NoticePublicationID,
func (w *DocumentSummarizationWorker) summarizeTender(t *tender.Tender) {
if strings.TrimSpace(t.NoticePublicationID) == "" {
w.Logger.Warn("Skipping tender summarization: missing notice_publication_id", map[string]interface{}{
"tender_id": t.ID.Hex(),
})
return
}
if strings.TrimSpace(t.ContractFolderID) == "" {
w.Logger.Warn("Skipping tender summarization: missing contract_folder_id", map[string]interface{}{
"tender_id": t.ID.Hex(),
})
t.ProcessingMetadata.DocumentsSummarized = true
t.ProcessingMetadata.DocumentsSummarizedAt = time.Now().Unix()
_ = w.updateTenderSummarizationStatus(t)
return
}
summaries := make([]tender.DocumentSummary, 0, len(t.ScrapedDocuments))
summarizedAt := time.Now().Unix()
ctx := context.Background()
if w.isSummaryReadyInStorage(ctx, t) {
w.markTenderSummarized(t)
return
}
for _, doc := range t.ScrapedDocuments {
summaryText, model, errStr := w.summarizeDocumentViaAI(context.Background(), t, doc)
if errStr != "" {
w.Logger.Error("Failed to summarize document", map[string]interface{}{
"tender_id": t.ID.Hex(),
"notice_publication_id": t.NoticePublicationID,
"document_name": doc.Filename,
"object_name": doc.ObjectName,
"error": errStr,
})
summaries = append(summaries, tender.DocumentSummary{
DocumentName: doc.Filename,
ObjectName: doc.ObjectName,
BucketName: doc.BucketName,
Summary: "",
SummaryLanguage: "en",
DocumentType: w.getDocumentType(doc.Filename),
SummarizedAt: summarizedAt,
SummaryModel: model,
Error: errStr,
})
continue
}
summaries = append(summaries, tender.DocumentSummary{
DocumentName: doc.Filename,
ObjectName: doc.ObjectName,
BucketName: doc.BucketName,
Summary: summaryText,
SummaryLanguage: "en",
DocumentType: w.getDocumentType(doc.Filename),
SummarizedAt: summarizedAt,
SummaryModel: model,
})
w.Logger.Info("Document summarized successfully", map[string]interface{}{
req := ai_summarizer.NewSummarizeRequest(t.ContractFolderID, t.NoticePublicationID)
if _, err := w.AIClient.FetchSummaryOnDemand(ctx, req); err != nil {
w.Logger.Error("Failed to summarize tender via AI service", map[string]interface{}{
"tender_id": t.ID.Hex(),
"notice_publication_id": t.NoticePublicationID,
"contract_folder_id": t.ContractFolderID,
"error": err.Error(),
})
return
}
if !w.isSummaryReadyInStorage(ctx, t) {
w.Logger.Warn("AI summarize completed but storage is not ready yet", map[string]interface{}{
"tender_id": t.ID.Hex(),
"notice_publication_id": t.NoticePublicationID,
"document_name": doc.Filename,
"summary_length": len(summaryText),
})
}
t.DocumentSummaries = summaries
t.ProcessingMetadata.DocumentsSummarized = true
t.ProcessingMetadata.DocumentsSummarizedAt = summarizedAt
w.markTenderSummarized(t)
}
if err := w.updateTenderSummarizationStatus(t); err != nil {
func (w *DocumentSummarizationWorker) isSummaryReadyInStorage(ctx context.Context, t *tender.Tender) bool {
if w.AIStorage == nil {
return false
}
ready, err := w.AIStorage.IsSummaryReady(ctx, t.ContractFolderID, t.NoticePublicationID)
if err != nil {
w.Logger.Debug("Could not check summarization status in storage", map[string]interface{}{
"tender_id": t.ID.Hex(),
"notice_publication_id": t.NoticePublicationID,
"error": err.Error(),
})
return false
}
return ready
}
func (w *DocumentSummarizationWorker) markTenderSummarized(t *tender.Tender) {
now := time.Now().Unix()
t.ProcessingMetadata.DocumentsSummarized = true
t.ProcessingMetadata.DocumentsSummarizedAt = now
t.UpdatedAt = now
if err := w.TenderRepo.Update(context.Background(), t); err != nil {
w.Logger.Error("Failed to update tender summarization status", map[string]interface{}{
"tender_id": t.ID.Hex(),
"notice_publication_id": t.NoticePublicationID,
@@ -152,118 +157,8 @@ func (w *DocumentSummarizationWorker) summarizeDocumentsForTender(t *tender.Tend
return
}
w.Logger.Info("Document summarization completed for tender", map[string]interface{}{
w.Logger.Info("Tender marked as summarized", map[string]interface{}{
"tender_id": t.ID.Hex(),
"notice_publication_id": t.NoticePublicationID,
"summarized_count": len(summaries),
"total_documents": len(t.ScrapedDocuments),
})
}
func (w *DocumentSummarizationWorker) summarizeDocumentViaAI(ctx context.Context, t *tender.Tender, doc tender.ScrapedDocument) (summaryText, model, errStr string) {
docURL, err := w.MinioSDK.GetPresignedURL(ctx, doc.BucketName, doc.ObjectName, presignedDocumentURLSeconds)
if err != nil {
return "", "", err.Error()
}
if strings.TrimSpace(t.NoticePublicationID) == "" {
return "", "", "tender has no notice_publication_id for AI summarize request"
}
if strings.TrimSpace(t.ContractFolderID) == "" {
return "", "", "tender has no contract_folder_id for AI summarize request"
}
req := ai_summarizer.NewSummarizeRequest(ai_summarizer.SummarizeRequestInput{
ContractFolderID: t.ContractFolderID,
NoticePublicationID: t.NoticePublicationID,
DocumentURL: docURL,
Title: t.Title,
Description: t.Description,
Country: t.CountryCode,
Currency: t.Currency,
SubmissionDeadline: t.SubmissionDeadline,
EstimatedValue: t.EstimatedValue,
AuthorityName: authorityNameFromTender(t),
})
resp, err := w.AIClient.FetchSummaryOnDemand(ctx, req)
if err != nil {
return "", "", err.Error()
}
if resp == nil {
return "", "", "AI summarize returned nil response"
}
summaryText, model = pickDocumentSummary(resp.Summaries, doc.Filename)
if summaryText == "" && len(resp.Summaries) > 0 {
// Fall back to first summary if filename did not match
summaryText = resp.Summaries[0].Summary
model = resp.Summaries[0].SummaryModel
}
if summaryText == "" {
return "", model, "AI summarize returned no summary text for document"
}
return summaryText, model, ""
}
func authorityNameFromTender(t *tender.Tender) string {
if t == nil || t.BuyerOrganization == nil {
return ""
}
return strings.TrimSpace(t.BuyerOrganization.Name)
}
func pickDocumentSummary(items []ai_summarizer.DocumentSummary, filename string) (text, model string) {
filename = strings.TrimSpace(filename)
for _, s := range items {
if strings.EqualFold(strings.TrimSpace(s.DocumentName), filename) {
return strings.TrimSpace(s.Summary), strings.TrimSpace(s.SummaryModel)
}
}
for _, s := range items {
if strings.TrimSpace(s.Summary) != "" {
return strings.TrimSpace(s.Summary), strings.TrimSpace(s.SummaryModel)
}
}
return "", ""
}
func (w *DocumentSummarizationWorker) getDocumentType(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".xlsx":
return "xlsx"
case ".pdf":
return "pdf"
case ".docx":
return "docx"
case ".doc":
return "doc"
case ".txt":
return "txt"
case ".html", ".htm":
return "html"
default:
return "unknown"
}
}
func (w *DocumentSummarizationWorker) updateTenderSummarizationStatus(tender *tender.Tender) error {
err := w.TenderRepo.Update(context.Background(), tender)
if err != nil {
w.Logger.Error("Failed to update tender summarization status", map[string]interface{}{
"tender_id": tender.ID.Hex(),
"error": err.Error(),
})
return err
}
w.Logger.Debug("Updated tender summarization status", map[string]interface{}{
"tender_id": tender.ID.Hex(),
"documents_summarized": tender.ProcessingMetadata.DocumentsSummarized,
"documents_summarized_at": tender.ProcessingMetadata.DocumentsSummarizedAt,
"summary_count": len(tender.DocumentSummaries),
})
return nil
}
+42 -103
View File
@@ -2,7 +2,6 @@ package workers
import (
"context"
"errors"
"strings"
"tm/internal/tender"
"tm/pkg/ai_summarizer"
@@ -10,17 +9,18 @@ import (
"tm/pkg/mongo"
)
// TranslationWorker backfills missing tender translations using the AI pipeline
// and on-demand translate API. Results are persisted by the AI service in MinIO only.
// TranslationWorker syncs translation completion from the AI pipeline MinIO storage.
// Batch translation is triggered via POST /pipeline/translate; this worker only
// verifies which tenders now have translations available in storage.
type TranslationWorker struct {
Mongo *mongo.ConnectionManager
Logger logger.Logger
TenderRepo tender.TenderRepository
AIClient *ai_summarizer.Client
AIStorage *ai_summarizer.StorageClient
Mongo *mongo.ConnectionManager
Logger logger.Logger
TenderRepo tender.TenderRepository
AIClient *ai_summarizer.Client
AIStorage *ai_summarizer.StorageClient
TranslationSuccessCounter *mongo.Counter
TargetLanguages []string
BatchSize int
TargetLanguages []string
BatchSize int
}
// NewTranslationWorker creates a translation worker for the given target languages.
@@ -66,7 +66,7 @@ func NewTranslationWorker(
}
}
// Run starts backfilling translations for tenders missing target-language content.
// Run triggers the AI translation pipeline and logs tenders with ready translations in MinIO.
func (w *TranslationWorker) Run() {
if w.AIClient == nil {
w.Logger.Warn("Translation worker skipped: AI client is not configured", map[string]interface{}{
@@ -83,14 +83,15 @@ func (w *TranslationWorker) Run() {
startCount := w.getSuccessfulTranslationCount(mongo.AITranslationSuccessCounterKeyDailyJob)
if _, err := w.AIClient.TriggerPipelineTranslate(context.Background(), w.TargetLanguages); err != nil {
w.Logger.Warn("Failed to trigger AI pipeline translate (continuing with per-tender sync)", map[string]interface{}{
w.Logger.Warn("Failed to trigger AI pipeline translate", map[string]interface{}{
"target_languages": w.TargetLanguages,
"error": err.Error(),
})
}
readyCount := 0
for _, language := range w.TargetLanguages {
w.runForLanguage(language)
readyCount += w.syncLanguageFromStorage(language)
}
endCount := w.getSuccessfulTranslationCount(mongo.AITranslationSuccessCounterKeyDailyJob)
@@ -101,10 +102,11 @@ func (w *TranslationWorker) Run() {
}
w.Logger.Info("Translation worker completed", map[string]interface{}{
"target_languages": w.TargetLanguages,
"successful_ai_translation_requests_run": runCount,
"successful_ai_translation_requests_daily_job": endCount,
"successful_ai_translation_requests_total": totalCount,
"target_languages": w.TargetLanguages,
"translations_ready_in_storage": readyCount,
"successful_ai_translation_requests_run": runCount,
"successful_ai_translation_requests_daily_job": endCount,
"successful_ai_translation_requests_total": totalCount,
})
}
@@ -123,19 +125,27 @@ func (w *TranslationWorker) getSuccessfulTranslationCount(key string) int64 {
return count
}
func (w *TranslationWorker) runForLanguage(language string) {
func (w *TranslationWorker) syncLanguageFromStorage(language string) int {
if w.AIStorage == nil {
w.Logger.Warn("Translation storage client not configured; skipping MinIO sync", map[string]interface{}{
"target_language": language,
})
return 0
}
readyCount := 0
skip := 0
for {
tenders, totalCount, err := w.TenderRepo.GetUnTranslatedTenders(context.Background(), language, w.BatchSize, skip)
if err != nil {
w.Logger.Error("Failed to fetch tenders for translation backfill", map[string]interface{}{
w.Logger.Error("Failed to fetch tenders for translation sync", map[string]interface{}{
"target_language": language,
"error": err.Error(),
})
return
return readyCount
}
w.Logger.Info("Fetched tenders for translation backfill", map[string]interface{}{
w.Logger.Info("Fetched tenders for translation sync", map[string]interface{}{
"target_language": language,
"batch_count": len(tenders),
"total_count": totalCount,
@@ -143,63 +153,29 @@ func (w *TranslationWorker) runForLanguage(language string) {
})
if len(tenders) == 0 {
return
return readyCount
}
for _, t := range tenders {
w.translateTender(&t, language)
if w.hasTranslationInStorage(&t, language) {
readyCount++
w.Logger.Debug("Translation available in MinIO", map[string]interface{}{
"tender_id": t.ID.Hex(),
"notice_id": t.NoticePublicationID,
"target_language": language,
})
}
}
skip += len(tenders)
if int64(skip) >= totalCount {
return
return readyCount
}
}
}
func (w *TranslationWorker) translateTender(t *tender.Tender, language string) {
if t.NoticePublicationID == "" {
return
}
if strings.TrimSpace(t.ContractFolderID) == "" {
w.Logger.Debug("Skipping tender translation: missing contract_folder_id", map[string]interface{}{
"tender_id": t.ID.Hex(),
})
return
}
if w.hasTranslationInStorage(t, language) {
w.Logger.Debug("Skipping tender translation: already available in MinIO", map[string]interface{}{
"tender_id": t.ID.Hex(),
"notice_id": t.NoticePublicationID,
"target_language": language,
})
return
}
title, description, source, err := w.resolveTranslation(t, language)
if err != nil {
w.Logger.Error("Failed to translate tender", map[string]interface{}{
"tender_id": t.ID.Hex(),
"notice_id": t.NoticePublicationID,
"target_language": language,
"translation_source": source,
"translation_error": err.Error(),
})
return
}
w.Logger.Info("Tender translated successfully", map[string]interface{}{
"tender_id": t.ID.Hex(),
"notice_id": t.NoticePublicationID,
"target_language": language,
"translation_source": source,
"title_len": len(title),
"description_len": len(description),
})
}
func (w *TranslationWorker) hasTranslationInStorage(t *tender.Tender, language string) bool {
if w.AIStorage == nil {
if strings.TrimSpace(t.ContractFolderID) == "" || t.NoticePublicationID == "" {
return false
}
stored, err := w.AIStorage.GetTranslationFromStorage(
@@ -210,40 +186,3 @@ func (w *TranslationWorker) hasTranslationInStorage(t *tender.Tender, language s
)
return err == nil && strings.TrimSpace(stored.Title) != ""
}
func (w *TranslationWorker) resolveTranslation(t *tender.Tender, language string) (title, description, source string, err error) {
if w.AIStorage != nil {
stored, storageErr := w.AIStorage.GetTranslationFromStorage(
context.Background(),
t.ContractFolderID,
t.NoticePublicationID,
language,
)
if storageErr == nil {
return stored.Title, stored.Description, "storage", nil
}
if !errors.Is(storageErr, ai_summarizer.ErrTranslationNotReady) &&
!errors.Is(storageErr, ai_summarizer.ErrObjectNotFound) &&
!errors.Is(storageErr, ai_summarizer.ErrNoTranslation) {
w.Logger.Debug("Translation not available from storage yet", map[string]interface{}{
"tender_id": t.ID.Hex(),
"notice_id": t.NoticePublicationID,
"target_language": language,
"error": storageErr.Error(),
})
}
}
resp, apiErr := w.AIClient.FetchTranslationOnDemand(context.Background(), ai_summarizer.TranslateRequest{
ContractFolderID: t.ContractFolderID,
NoticePublicationID: t.NoticePublicationID,
Title: t.Title,
Description: t.Description,
Language: language,
RequestSource: ai_summarizer.TranslationRequestSourceDailyJob,
})
if apiErr != nil {
return "", "", "api", apiErr
}
return resp.TranslatedTitle, resp.TranslatedDescription, "api", nil
}