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:
@@ -573,3 +573,23 @@ type AIDocumentSummary struct {
|
||||
SummaryModel string `json:"summary_model"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// AIAnalyzeResponse represents the response for on-demand agentic analysis.
|
||||
type AIAnalyzeResponse struct {
|
||||
ContractFolderID string `json:"contract_folder_id"`
|
||||
NoticePublicationID string `json:"notice_publication_id"`
|
||||
Summary string `json:"summary"`
|
||||
Documents []AIAnalyzeDocument `json:"documents"`
|
||||
ToolCallsLog []string `json:"tool_calls_log"`
|
||||
Iterations int `json:"iterations"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// AIAnalyzeDocument describes one document referenced during analysis.
|
||||
type AIAnalyzeDocument struct {
|
||||
Filename string `json:"filename"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
TimesRead int `json:"times_read"`
|
||||
TimesQueried int `json:"times_queried"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
@@ -398,7 +398,7 @@ func (h *TenderHandler) GetPublicTenderDetails(c echo.Context) error {
|
||||
|
||||
// GetDocumentSummaries retrieves document summaries for a tender
|
||||
// @Summary Get document summaries for a tender
|
||||
// @Description Retrieve AI-generated summaries of documents for a specific tender
|
||||
// @Description Retrieve per-document AI summaries from the AI service MinIO storage for a specific tender
|
||||
// @Tags Tenders
|
||||
// @Produce json
|
||||
// @Param id path string true "Tender ID"
|
||||
@@ -595,6 +595,36 @@ func (h *TenderHandler) TriggerAISummarize(c echo.Context) error {
|
||||
return response.Success(c, result, "AI summarization completed successfully")
|
||||
}
|
||||
|
||||
// TriggerAIAnalyze triggers on-demand agentic analysis for a tender
|
||||
// @Summary Trigger AI analysis
|
||||
// @Description Trigger on-demand agentic document analysis for a tender via the external AI service
|
||||
// @Tags Admin-Tenders
|
||||
// @Produce json
|
||||
// @Param id path string true "Tender ID"
|
||||
// @Success 200 {object} response.APIResponse{data=AIAnalyzeResponse}
|
||||
// @Failure 400 {object} response.APIResponse
|
||||
// @Failure 404 {object} response.APIResponse
|
||||
// @Failure 500 {object} response.APIResponse
|
||||
// @Router /admin/v1/tenders/{id}/ai-analyze [post]
|
||||
// @Security BearerAuth
|
||||
func (h *TenderHandler) TriggerAIAnalyze(c echo.Context) error {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
|
||||
}
|
||||
|
||||
result, err := h.service.TriggerAIAnalyze(c.Request().Context(), id)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to trigger AI analysis", map[string]interface{}{
|
||||
"tender_id": id,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return response.InternalServerError(c, err.Error())
|
||||
}
|
||||
|
||||
return response.Success(c, result, "AI analysis completed successfully")
|
||||
}
|
||||
|
||||
// TriggerAITranslate triggers on-demand AI translation for a tender
|
||||
// @Summary Trigger AI translation
|
||||
// @Description Trigger on-demand AI translation for tender title and description (cached in MinIO by the AI service)
|
||||
|
||||
+153
-34
@@ -18,10 +18,11 @@ import (
|
||||
"tm/pkg/response"
|
||||
)
|
||||
|
||||
// AISummarizerClient defines the interface for on-demand AI summarization
|
||||
// AISummarizerClient defines the interface for on-demand AI operations.
|
||||
type AISummarizerClient interface {
|
||||
FetchSummaryOnDemand(ctx context.Context, reqBody ai_summarizer.SummarizeRequest) (*ai_summarizer.SummarizeResponse, error)
|
||||
FetchTranslationOnDemand(ctx context.Context, reqBody ai_summarizer.TranslateRequest) (*ai_summarizer.TranslateResponse, error)
|
||||
FetchAnalyzeOnDemand(ctx context.Context, reqBody ai_summarizer.AnalyzeRequest) (*ai_summarizer.AnalyzeResponse, error)
|
||||
TriggerPipelineTranslate(ctx context.Context, languages []string) (*ai_summarizer.PipelineTranslateResponse, error)
|
||||
}
|
||||
|
||||
@@ -30,6 +31,7 @@ type AISummarizerClient interface {
|
||||
// PROC_<contractFolderID>/<noticePublicationID>/ in the shared bucket.
|
||||
type AISummarizerStorage interface {
|
||||
GetSummaryFromStorage(ctx context.Context, contractFolderID, noticePublicationID string) (string, error)
|
||||
GetDocumentSummariesFromStorage(ctx context.Context, contractFolderID, noticePublicationID string) ([]ai_summarizer.DocumentSummary, error)
|
||||
GetTranslationFromStorage(ctx context.Context, contractFolderID, noticePublicationID, language string) (ai_summarizer.StoredTranslation, error)
|
||||
ListDocuments(ctx context.Context, contractFolderID, noticePublicationID string) ([]ai_summarizer.StoredDocument, error)
|
||||
DownloadDocument(ctx context.Context, objectName string) (io.ReadCloser, error)
|
||||
@@ -60,6 +62,7 @@ type Service interface {
|
||||
// AI summarization operations
|
||||
GetAISummary(ctx context.Context, id string) (*AISummaryResponse, error)
|
||||
TriggerAISummarize(ctx context.Context, id string) (*AISummarizeResponse, error)
|
||||
TriggerAIAnalyze(ctx context.Context, id string) (*AIAnalyzeResponse, error)
|
||||
TriggerAITranslate(ctx context.Context, id, language string) (*AITranslateResponse, error)
|
||||
GetAllAISummaries(ctx context.Context) ([]ai_summarizer.TenderSummaryItem, error)
|
||||
// ListTendersWithScrapedDocuments lists tenders that have documents in MinIO. When syncMongoMetadata is true,
|
||||
@@ -451,14 +454,13 @@ func (s *tenderService) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDocumentSummaries retrieves document summaries for a specific tender
|
||||
// GetDocumentSummaries retrieves per-document summaries from the AI service MinIO storage.
|
||||
func (s *tenderService) GetDocumentSummaries(ctx context.Context, id string) ([]DocumentSummary, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("tender ID cannot be empty")
|
||||
}
|
||||
|
||||
// Get tender from repository
|
||||
tender, err := s.repository.GetByID(ctx, id)
|
||||
t, err := s.repository.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get tender for document summaries", map[string]interface{}{
|
||||
"tender_id": id,
|
||||
@@ -466,22 +468,60 @@ func (s *tenderService) GetDocumentSummaries(ctx context.Context, id string) ([]
|
||||
})
|
||||
return nil, fmt.Errorf("failed to get tender: %w", err)
|
||||
}
|
||||
|
||||
if tender == nil {
|
||||
if t == nil {
|
||||
return nil, fmt.Errorf("tender not found")
|
||||
}
|
||||
|
||||
// Return document summaries (empty slice if none exist)
|
||||
if tender.DocumentSummaries == nil {
|
||||
if strings.TrimSpace(t.NoticePublicationID) == "" || strings.TrimSpace(t.ContractFolderID) == "" {
|
||||
return []DocumentSummary{}, nil
|
||||
}
|
||||
if s.aiSummarizerStorage == nil {
|
||||
return []DocumentSummary{}, nil
|
||||
}
|
||||
|
||||
stored, err := s.aiSummarizerStorage.GetDocumentSummariesFromStorage(ctx, t.ContractFolderID, t.NoticePublicationID)
|
||||
if errors.Is(err, ai_summarizer.ErrSummaryNotReady) {
|
||||
return []DocumentSummary{}, nil
|
||||
}
|
||||
if errors.Is(err, ai_summarizer.ErrObjectNotFound) {
|
||||
return []DocumentSummary{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) {
|
||||
return nil, fmt.Errorf("failed to get document summaries: MinIO connection unavailable: %w", err)
|
||||
}
|
||||
s.logger.Error("Failed to get document summaries from storage", map[string]interface{}{
|
||||
"tender_id": id,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to get document summaries: %w", err)
|
||||
}
|
||||
|
||||
summaries := mapAIDocumentSummaries(stored)
|
||||
s.logger.Info("Retrieved document summaries for tender", map[string]interface{}{
|
||||
"tender_id": id,
|
||||
"summaries_count": len(tender.DocumentSummaries),
|
||||
"summaries_count": len(summaries),
|
||||
"source": "storage",
|
||||
})
|
||||
|
||||
return tender.DocumentSummaries, nil
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func mapAIDocumentSummaries(items []ai_summarizer.DocumentSummary) []DocumentSummary {
|
||||
if len(items) == 0 {
|
||||
return []DocumentSummary{}
|
||||
}
|
||||
out := make([]DocumentSummary, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, DocumentSummary{
|
||||
DocumentName: item.DocumentName,
|
||||
Summary: item.Summary,
|
||||
SummaryLanguage: "en",
|
||||
DocumentType: item.DocumentType,
|
||||
SummaryModel: item.SummaryModel,
|
||||
Error: item.Error,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetDocuments retrieves scraped document metadata for a tender.
|
||||
@@ -1175,23 +1215,7 @@ func (s *tenderService) TriggerAISummarize(ctx context.Context, id string) (*AIS
|
||||
return nil, fmt.Errorf("tender has no contract folder ID")
|
||||
}
|
||||
|
||||
authorityName := ""
|
||||
if t.BuyerOrganization != nil {
|
||||
authorityName = t.BuyerOrganization.Name
|
||||
}
|
||||
|
||||
reqBody := ai_summarizer.NewSummarizeRequest(ai_summarizer.SummarizeRequestInput{
|
||||
ContractFolderID: t.ContractFolderID,
|
||||
NoticePublicationID: t.NoticePublicationID,
|
||||
DocumentURL: t.DocumentURI,
|
||||
Title: t.Title,
|
||||
Description: t.Description,
|
||||
Country: t.CountryCode,
|
||||
Currency: t.Currency,
|
||||
SubmissionDeadline: t.SubmissionDeadline,
|
||||
EstimatedValue: t.EstimatedValue,
|
||||
AuthorityName: authorityName,
|
||||
})
|
||||
reqBody := ai_summarizer.NewSummarizeRequest(t.ContractFolderID, t.NoticePublicationID)
|
||||
|
||||
s.logger.Info("Triggering on-demand AI summarization", map[string]interface{}{
|
||||
"tender_id": id,
|
||||
@@ -1247,6 +1271,89 @@ func (s *tenderService) TriggerAISummarize(ctx context.Context, id string) (*AIS
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TriggerAIAnalyze triggers on-demand agentic analysis for a tender.
|
||||
func (s *tenderService) TriggerAIAnalyze(ctx context.Context, id string) (*AIAnalyzeResponse, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("tender ID cannot be empty")
|
||||
}
|
||||
if s.aiSummarizerClient == nil {
|
||||
return nil, fmt.Errorf("AI summarizer service is not configured")
|
||||
}
|
||||
|
||||
t, err := s.repository.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get tender for on-demand analysis", map[string]interface{}{
|
||||
"tender_id": id,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to get tender: %w", err)
|
||||
}
|
||||
if t == nil {
|
||||
return nil, fmt.Errorf("tender not found")
|
||||
}
|
||||
if t.NoticePublicationID == "" {
|
||||
return nil, fmt.Errorf("tender has no notice publication ID")
|
||||
}
|
||||
if strings.TrimSpace(t.ContractFolderID) == "" {
|
||||
return nil, fmt.Errorf("tender has no contract folder ID")
|
||||
}
|
||||
|
||||
reqBody := ai_summarizer.NewAnalyzeRequest(t.ContractFolderID, t.NoticePublicationID)
|
||||
s.logger.Info("Triggering on-demand AI analysis", map[string]interface{}{
|
||||
"tender_id": id,
|
||||
"contract_folder_id": t.ContractFolderID,
|
||||
"notice_publication_id": t.NoticePublicationID,
|
||||
})
|
||||
|
||||
resp, err := s.aiSummarizerClient.FetchAnalyzeOnDemand(ctx, reqBody)
|
||||
if err != nil {
|
||||
s.logger.Error("On-demand AI analysis failed", map[string]interface{}{
|
||||
"tender_id": id,
|
||||
"contract_folder_id": t.ContractFolderID,
|
||||
"notice_publication_id": t.NoticePublicationID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("AI analysis request failed: %w", err)
|
||||
}
|
||||
|
||||
documents := make([]AIAnalyzeDocument, 0, len(resp.Documents))
|
||||
for _, doc := range resp.Documents {
|
||||
documents = append(documents, AIAnalyzeDocument{
|
||||
Filename: doc.Filename,
|
||||
SizeBytes: doc.SizeBytes,
|
||||
TimesRead: doc.TimesRead,
|
||||
TimesQueried: doc.TimesQueried,
|
||||
Description: doc.Description,
|
||||
})
|
||||
}
|
||||
|
||||
noticePublicationID := strings.TrimSpace(resp.NoticePublicationID)
|
||||
if noticePublicationID == "" {
|
||||
noticePublicationID = t.NoticePublicationID
|
||||
}
|
||||
contractFolderID := strings.TrimSpace(resp.ContractFolderID)
|
||||
if contractFolderID == "" {
|
||||
contractFolderID = t.ContractFolderID
|
||||
}
|
||||
|
||||
s.logger.Info("On-demand AI analysis completed", map[string]interface{}{
|
||||
"tender_id": id,
|
||||
"contract_folder_id": contractFolderID,
|
||||
"notice_publication_id": noticePublicationID,
|
||||
"iterations": resp.Iterations,
|
||||
})
|
||||
|
||||
return &AIAnalyzeResponse{
|
||||
ContractFolderID: contractFolderID,
|
||||
NoticePublicationID: noticePublicationID,
|
||||
Summary: resp.Summary,
|
||||
Documents: documents,
|
||||
ToolCallsLog: resp.ToolCallsLog,
|
||||
Iterations: resp.Iterations,
|
||||
Model: resp.Model,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAllAISummaries retrieves all AI-generated summaries from the MinIO storage.
|
||||
// It lists all tender.json files in the bucket and returns summaries for tenders
|
||||
// where the AI pipeline has completed processing.
|
||||
@@ -1510,11 +1617,12 @@ func (s *tenderService) buildAITranslateResponse(t *Tender, language, title, des
|
||||
|
||||
func (s *tenderService) resolveTranslation(ctx context.Context, t *Tender, language, requestSource string) (string, string, bool, error) {
|
||||
if s.aiSummarizerStorage != nil {
|
||||
stored, err := s.aiSummarizerStorage.GetTranslationFromStorage(ctx, t.ContractFolderID, t.NoticePublicationID, language)
|
||||
if err == nil {
|
||||
stored, _, err := s.lookupTranslationForTender(ctx, t, language)
|
||||
if err == nil && strings.TrimSpace(stored.Title) != "" {
|
||||
return stored.Title, stored.Description, false, nil
|
||||
}
|
||||
if !errors.Is(err, ai_summarizer.ErrTranslationNotReady) &&
|
||||
if err != nil &&
|
||||
!errors.Is(err, ai_summarizer.ErrTranslationNotReady) &&
|
||||
!errors.Is(err, ai_summarizer.ErrObjectNotFound) &&
|
||||
!errors.Is(err, ai_summarizer.ErrNoTranslation) {
|
||||
s.logger.Debug("Translation not available from storage", map[string]interface{}{
|
||||
@@ -1528,15 +1636,26 @@ func (s *tenderService) resolveTranslation(ctx context.Context, t *Tender, langu
|
||||
resp, err := s.aiSummarizerClient.FetchTranslationOnDemand(ctx, ai_summarizer.TranslateRequest{
|
||||
ContractFolderID: t.ContractFolderID,
|
||||
NoticePublicationID: t.NoticePublicationID,
|
||||
Title: t.Title,
|
||||
Description: t.Description,
|
||||
Language: language,
|
||||
RequestSource: requestSource,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
return resp.TranslatedTitle, resp.TranslatedDescription, true, nil
|
||||
|
||||
title := strings.TrimSpace(resp.TranslatedTitle)
|
||||
description := resp.TranslatedDescription
|
||||
if s.aiSummarizerStorage != nil {
|
||||
if stored, _, storageErr := s.lookupTranslationForTender(ctx, t, language); storageErr == nil && strings.TrimSpace(stored.Title) != "" {
|
||||
title = stored.Title
|
||||
description = stored.Description
|
||||
}
|
||||
}
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(resp.TranslatedTitle)
|
||||
}
|
||||
|
||||
return title, description, true, nil
|
||||
}
|
||||
|
||||
func (s *tenderService) pickResponseLanguage(language *string) string {
|
||||
|
||||
Reference in New Issue
Block a user