Refactor AI summarization functionality by removing document summarization worker
continuous-integration/drone/push Build is passing

- Removed the DocumentSummarizationWorker and its related scheduling logic from the worker bootstrap.
- Updated the AI summarizer client initialization comment for clarity.
- Added a new error type for cases when tender documents have not been scraped yet, enhancing error handling in the tender service.
- Modified API documentation to reflect changes in AI summary retrieval logic, ensuring accurate descriptions of on-demand summarization behavior.

This update streamlines the AI summarization process by eliminating the document summarization worker, improving overall system efficiency and clarity in error handling.
This commit is contained in:
Mazyar
2026-06-21 11:20:28 +03:30
parent e31bccced6
commit e04cdd1d10
6 changed files with 163 additions and 302 deletions
+157 -77
View File
@@ -168,44 +168,27 @@ func (s *tenderService) buildDetailResponse(ctx context.Context, tender *Tender,
return resp
}
// enrichWithAISummary loads overall_summary from MinIO (PROC_<contractFolderID>/<noticePublicationID>/tender.json)
// and attaches it to the response. The primary notice publication id is tried first, then related ids.
// A previously stored ai_overall_summary on the tender (already mapped in ToResponse) is kept when MinIO
// has no ready summary. On success the tender document is updated so list/search can return it without
// another MinIO round-trip.
// enrichWithAISummary attaches the overall AI summary to a tender detail response.
// It reads from MinIO when available; for scraped tenders without a summary it triggers
// on-demand summarization via the AI service (same path as GetAISummary).
func (s *tenderService) enrichWithAISummary(ctx context.Context, t *Tender, resp *TenderResponse) {
if t == nil || resp == nil {
return
}
if s.aiSummarizerStorage == nil || strings.TrimSpace(t.ContractFolderID) == "" {
return
}
enrichCtx, cancel := context.WithTimeout(ctx, aiSummaryEnrichmentTimeout)
defer cancel()
summary, usedNotice, err := s.lookupSummaryForTender(enrichCtx, t)
result, err := s.resolveAISummary(ctx, t)
if err != nil {
s.logger.Debug("AI summary not available for tender", map[string]interface{}{
"tender_id": resp.ID,
"contract_folder_id": t.ContractFolderID,
"notice_id": t.NoticePublicationID,
"error": err.Error(),
s.logger.Debug("AI summary resolution failed for tender detail", map[string]interface{}{
"tender_id": resp.ID,
"error": err.Error(),
})
return
}
resp.OverallSummary = summary
if usedNotice != t.NoticePublicationID {
s.logger.Debug("AI summary resolved from related notice", map[string]interface{}{
"tender_id": resp.ID,
"contract_folder_id": t.ContractFolderID,
"primary_notice_id": t.NoticePublicationID,
"used_notice_id": usedNotice,
})
if result == nil || strings.TrimSpace(result.OverallSummary) == "" {
return
}
s.persistAIOverallSummary(enrichCtx, t, summary)
resp.OverallSummary = result.OverallSummary
}
// enrichWithTranslation overlays title/description from MinIO when the pipeline has finished
@@ -1131,8 +1114,8 @@ func (s *tenderService) CleanupOldData(ctx context.Context, olderThan time.Durat
}
// GetAISummary retrieves the AI-generated summary for a tender.
// It loads from MinIO storage (async pipeline result). When no summary exists yet,
// the pipeline is still running, or storage is unavailable, it returns 200-compatible
// It reads from MinIO when available; for scraped tenders without a summary it triggers
// on-demand summarization. When no summary can be produced it returns 200-compatible
// data with an empty overall_summary and a Source hint instead of an error.
func (s *tenderService) GetAISummary(ctx context.Context, id string) (*AISummaryResponse, error) {
if id == "" {
@@ -1151,6 +1134,12 @@ func (s *tenderService) GetAISummary(ctx context.Context, id string) (*AISummary
return nil, fmt.Errorf("tender not found")
}
return s.resolveAISummary(ctx, t)
}
// resolveAISummary returns the overall AI summary for a tender, triggering on-demand
// summarization when documents are scraped but no summary exists in storage yet.
func (s *tenderService) resolveAISummary(ctx context.Context, t *Tender) (*AISummaryResponse, error) {
noticeID := strings.TrimSpace(t.NoticePublicationID)
emptyResp := func(source string) *AISummaryResponse {
return &AISummaryResponse{
@@ -1164,45 +1153,146 @@ func (s *tenderService) GetAISummary(ctx context.Context, id string) (*AISummary
return emptyResp("unavailable"), nil
}
if s.aiSummarizerStorage == nil {
s.logger.Debug("AI summarizer storage not configured; returning empty AI summary", map[string]interface{}{
"tender_id": id,
if s.aiSummarizerStorage == nil && s.aiSummarizerClient == nil {
s.logger.Debug("AI summarizer not configured; returning empty AI summary", map[string]interface{}{
"tender_id": t.ID.Hex(),
})
return emptyResp("unavailable"), nil
}
summary, usedNotice, err := s.lookupSummaryForTender(ctx, t)
if err == nil {
return &AISummaryResponse{
NoticeID: usedNotice,
OverallSummary: summary,
Source: "storage",
}, nil
lookupCtx := ctx
if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > aiSummaryEnrichmentTimeout {
var cancel context.CancelFunc
lookupCtx, cancel = context.WithTimeout(ctx, aiSummaryEnrichmentTimeout)
defer cancel()
}
s.logger.Info("AI summary not available from storage", map[string]interface{}{
"tender_id": id,
"contract_folder_id": t.ContractFolderID,
"notice_id": noticeID,
"error": err.Error(),
})
if s.aiSummarizerStorage != nil {
summary, usedNotice, err := s.lookupSummaryForTender(lookupCtx, t)
if err == nil {
s.persistAIOverallSummary(ctx, t, summary)
return &AISummaryResponse{
NoticeID: usedNotice,
OverallSummary: summary,
Source: "storage",
}, nil
}
if errors.Is(err, ai_summarizer.ErrSummaryNotReady) {
return emptyResp("pending"), nil
if !errors.Is(err, ai_summarizer.ErrSummaryNotReady) &&
!errors.Is(err, ai_summarizer.ErrObjectNotFound) &&
!errors.Is(err, ai_summarizer.ErrNoOverallSummary) {
if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) {
s.logger.Error("Cannot retrieve AI summary: MinIO connection unavailable", map[string]interface{}{
"tender_id": t.ID.Hex(),
"contract_folder_id": t.ContractFolderID,
"notice_id": noticeID,
"error": err.Error(),
})
return nil, fmt.Errorf("failed to get AI summary: MinIO connection unavailable: %w", err)
}
return nil, fmt.Errorf("failed to get AI summary: %w", err)
}
}
if errors.Is(err, ai_summarizer.ErrObjectNotFound) || errors.Is(err, ai_summarizer.ErrNoOverallSummary) {
if !s.tenderHasScrapedDocuments(t) {
return emptyResp("unavailable"), nil
}
if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) {
s.logger.Error("Cannot retrieve AI summary: MinIO connection unavailable", map[string]interface{}{
"tender_id": id,
if s.aiSummarizerClient == nil {
return emptyResp("unavailable"), nil
}
resp, err := s.fetchAISummaryOnDemand(ctx, t)
if err != nil {
s.logger.Error("On-demand AI summarization on tender read failed", map[string]interface{}{
"tender_id": t.ID.Hex(),
"contract_folder_id": t.ContractFolderID,
"notice_id": noticeID,
"error": err.Error(),
})
return nil, fmt.Errorf("failed to get AI summary: MinIO connection unavailable: %w", err)
return emptyResp("pending"), nil
}
return nil, fmt.Errorf("failed to get AI summary: %w", err)
overall := derefString(resp.OverallSummary)
usedNotice := resp.ResolvedNoticePublicationID()
if usedNotice == "" {
usedNotice = noticeID
}
if overall == "" && s.aiSummarizerStorage != nil {
if summary, notice, lookupErr := s.lookupSummaryForTender(ctx, t); lookupErr == nil {
overall = summary
usedNotice = notice
}
}
if overall != "" {
s.persistAIOverallSummary(ctx, t, overall)
return &AISummaryResponse{
NoticeID: usedNotice,
OverallSummary: overall,
Source: "generated",
}, nil
}
return emptyResp("pending"), nil
}
func (s *tenderService) tenderHasScrapedDocuments(t *Tender) bool {
if t == nil {
return false
}
if t.ProcessingMetadata.DocumentsScraped {
return true
}
return len(t.ScrapedDocuments) > 0
}
func (s *tenderService) fetchAISummaryOnDemand(ctx context.Context, t *Tender) (*ai_summarizer.SummarizeResponse, error) {
if s.aiSummarizerClient == nil {
return nil, ErrAINotConfigured
}
if strings.TrimSpace(t.NoticePublicationID) == "" {
return nil, ErrTenderMissingNoticeID
}
if strings.TrimSpace(t.ContractFolderID) == "" {
return nil, ErrTenderMissingContractFolderID
}
reqBody := ai_summarizer.NewSummarizeRequest(t.ContractFolderID, t.NoticePublicationID)
s.logger.Info("Triggering on-demand AI summarization", map[string]interface{}{
"tender_id": t.ID.Hex(),
"contract_folder_id": t.ContractFolderID,
"notice_publication_id": t.NoticePublicationID,
})
resp, err := s.aiSummarizerClient.FetchSummaryOnDemand(ctx, reqBody)
if err != nil {
return nil, fmt.Errorf("AI summarization request failed: %w", err)
}
s.markTenderSummarized(ctx, t)
return resp, nil
}
func (s *tenderService) markTenderSummarized(ctx context.Context, t *Tender) {
now := time.Now().Unix()
t.ProcessingMetadata.DocumentsSummarized = true
t.ProcessingMetadata.DocumentsSummarizedAt = now
t.UpdatedAt = now
if err := s.repository.Update(ctx, t); err != nil {
s.logger.Warn("Failed to update tender summarization status", map[string]interface{}{
"tender_id": t.ID.Hex(),
"error": err.Error(),
})
}
}
func derefString(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}
// TriggerAISummarize triggers on-demand AI summarization for a tender.
@@ -1216,7 +1306,6 @@ func (s *tenderService) TriggerAISummarize(ctx context.Context, id string) (*AIS
return nil, ErrAINotConfigured
}
// Get tender to build the request
t, err := s.repository.GetByID(ctx, id)
if err != nil {
s.logger.Error("Failed to get tender for on-demand summarization", map[string]interface{}{
@@ -1236,23 +1325,11 @@ func (s *tenderService) TriggerAISummarize(ctx context.Context, id string) (*AIS
return nil, ErrTenderNotFound
}
if t.NoticePublicationID == "" {
return nil, ErrTenderMissingNoticeID
}
if strings.TrimSpace(t.ContractFolderID) == "" {
return nil, ErrTenderMissingContractFolderID
if !s.tenderHasScrapedDocuments(t) {
return nil, ErrTenderDocumentsNotScraped
}
reqBody := ai_summarizer.NewSummarizeRequest(t.ContractFolderID, t.NoticePublicationID)
s.logger.Info("Triggering on-demand AI summarization", map[string]interface{}{
"tender_id": id,
"contract_folder_id": t.ContractFolderID,
"notice_publication_id": t.NoticePublicationID,
})
// Call the AI service
resp, err := s.aiSummarizerClient.FetchSummaryOnDemand(ctx, reqBody)
resp, err := s.fetchAISummaryOnDemand(ctx, t)
if err != nil {
s.logger.Error("On-demand AI summarization failed", map[string]interface{}{
"tender_id": id,
@@ -1260,18 +1337,17 @@ func (s *tenderService) TriggerAISummarize(ctx context.Context, id string) (*AIS
"notice_publication_id": t.NoticePublicationID,
"error": err.Error(),
})
return nil, fmt.Errorf("AI summarization request failed: %w", err)
return nil, err
}
// Map the response to our response type
summaries := make([]AIDocumentSummary, 0, len(resp.Summaries))
for _, s := range resp.Summaries {
for _, item := range resp.Summaries {
summaries = append(summaries, AIDocumentSummary{
DocumentName: s.DocumentName,
DocumentType: s.DocumentType,
Summary: s.Summary,
SummaryModel: s.SummaryModel,
Error: s.Error,
DocumentName: item.DocumentName,
DocumentType: item.DocumentType,
Summary: item.Summary,
SummaryModel: item.SummaryModel,
Error: item.Error,
})
}
@@ -1291,6 +1367,10 @@ func (s *tenderService) TriggerAISummarize(ctx context.Context, id string) (*AIS
contractFolderID = t.ContractFolderID
}
if overall := derefString(resp.OverallSummary); overall != "" {
s.persistAIOverallSummary(ctx, t, overall)
}
return &AISummarizeResponse{
ContractFolderID: contractFolderID,
NoticePublicationID: noticePublicationID,