68b170126d
- Updated `InitAISummarizerClient` to accept `mongoManager` for tracking translation success. - Introduced new `Statistics` endpoint in the dashboard to fetch scraping and translation statistics. - Enhanced `TranslationWorker` to utilize the new success counter for tracking successful translations. - Added necessary data structures and query forms for statistics reporting. This refactor improves the tracking of AI translation success and provides new insights through the dashboard statistics.
1551 lines
51 KiB
Go
1551 lines
51 KiB
Go
package tender
|
|
|
|
import (
|
|
"archive/zip"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
"tm/internal/company"
|
|
"tm/internal/customer"
|
|
"tm/pkg/ai_summarizer"
|
|
"tm/pkg/logger"
|
|
"tm/pkg/response"
|
|
)
|
|
|
|
// AISummarizerClient defines the interface for on-demand AI summarization
|
|
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)
|
|
TriggerPipelineTranslate(ctx context.Context, languages []string) (*ai_summarizer.PipelineTranslateResponse, error)
|
|
}
|
|
|
|
// AISummarizerStorage defines the interface for fetching summaries from MinIO storage.
|
|
// Paths follow the AI team's procedure-scoped layout: artefacts for a notice live under
|
|
// PROC_<contractFolderID>/<noticePublicationID>/ in the shared bucket.
|
|
type AISummarizerStorage interface {
|
|
GetSummaryFromStorage(ctx context.Context, contractFolderID, noticePublicationID string) (string, 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)
|
|
}
|
|
|
|
// TenderDocumentDownload contains an authorized document stream.
|
|
type TenderDocumentDownload struct {
|
|
Reader io.ReadCloser
|
|
Filename string
|
|
ContentType string
|
|
Size int64
|
|
}
|
|
|
|
// TenderService interface defines tender business logic operations
|
|
type Service interface {
|
|
// Tender operations
|
|
GetByID(ctx context.Context, id string, language string) (*TenderResponse, error)
|
|
GetByIDs(ctx context.Context, ids []string) (map[string]*TenderResponse, error)
|
|
Update(ctx context.Context, req UpdateTenderRequest) (*TenderResponse, error)
|
|
Delete(ctx context.Context, id string) error
|
|
Search(ctx context.Context, req *SearchForm, pagination *response.Pagination) (*SearchResponse, error)
|
|
Recommend(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error)
|
|
GetDocumentSummaries(ctx context.Context, id string) ([]DocumentSummary, error)
|
|
GetDocuments(ctx context.Context, id string) (*TenderDocumentsResponse, error)
|
|
DownloadDocuments(ctx context.Context, id string) (*TenderDocumentDownload, error)
|
|
DownloadDocument(ctx context.Context, id, filename string) (*TenderDocumentDownload, error)
|
|
|
|
// AI summarization operations
|
|
GetAISummary(ctx context.Context, id string) (*AISummaryResponse, error)
|
|
TriggerAISummarize(ctx context.Context, id string) (*AISummarizeResponse, 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,
|
|
// each tender is re-listed from MinIO and scraped document metadata is persisted to Mongo (slow).
|
|
ListTendersWithScrapedDocuments(ctx context.Context, pagination *response.Pagination, syncMongoMetadata bool) (*TendersWithScrapedDocumentsResponse, error)
|
|
|
|
// Maintenance operations
|
|
UpdateExpiredTenders(ctx context.Context) error
|
|
CleanupOldData(ctx context.Context, olderThan time.Duration, pagination *response.Pagination) error
|
|
}
|
|
|
|
// tenderService implements TenderService interface
|
|
type tenderService struct {
|
|
repository TenderRepository
|
|
companyService company.Service
|
|
customerService customer.Service
|
|
logger logger.Logger
|
|
aiSummarizerClient AISummarizerClient
|
|
aiSummarizerStorage AISummarizerStorage
|
|
defaultLanguage string
|
|
}
|
|
|
|
const (
|
|
aiSummaryEnrichmentTimeout = 2 * time.Second
|
|
aiTranslationEnrichmentTimeout = 2 * time.Second
|
|
)
|
|
|
|
var (
|
|
ErrTenderDocumentStorageUnavailable = errors.New("tender document storage is unavailable")
|
|
ErrTenderDocumentNotFound = errors.New("tender document not found")
|
|
)
|
|
|
|
// NewService creates a new tender service
|
|
func NewService(repository TenderRepository, companyService company.Service, customerService customer.Service, logger logger.Logger, aiClient AISummarizerClient, aiStorage AISummarizerStorage, defaultLanguage string) Service {
|
|
if strings.TrimSpace(defaultLanguage) == "" {
|
|
defaultLanguage = "en"
|
|
}
|
|
|
|
return &tenderService{
|
|
repository: repository,
|
|
companyService: companyService,
|
|
customerService: customerService,
|
|
logger: logger,
|
|
aiSummarizerClient: aiClient,
|
|
aiSummarizerStorage: aiStorage,
|
|
defaultLanguage: strings.ToLower(defaultLanguage),
|
|
}
|
|
}
|
|
|
|
// GetByID retrieves a tender by ID. When language is empty, the service default (usually en)
|
|
// is used to overlay title/description from MinIO translations when available.
|
|
func (s *tenderService) GetByID(ctx context.Context, id string, language string) (*TenderResponse, error) {
|
|
s.logger.Info("Retrieving tender by ID", map[string]interface{}{
|
|
"tender_id": id,
|
|
"language": s.resolveDetailLanguage(language),
|
|
})
|
|
|
|
tender, err := s.repository.GetByID(ctx, id)
|
|
if err != nil {
|
|
s.logger.Error("Failed to retrieve tender", map[string]interface{}{
|
|
"tender_id": id,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to retrieve tender: %w", err)
|
|
}
|
|
|
|
return s.buildDetailResponse(ctx, tender, language), nil
|
|
}
|
|
|
|
// GetByIDs loads multiple tenders for list endpoints without MinIO or AI enrichment.
|
|
func (s *tenderService) GetByIDs(ctx context.Context, ids []string) (map[string]*TenderResponse, error) {
|
|
if len(ids) == 0 {
|
|
return map[string]*TenderResponse{}, nil
|
|
}
|
|
|
|
tenders, err := s.repository.GetByIDs(ctx, ids)
|
|
if err != nil {
|
|
s.logger.Error("Failed to retrieve tenders by IDs", map[string]interface{}{
|
|
"count": len(ids),
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to retrieve tenders: %w", err)
|
|
}
|
|
|
|
out := make(map[string]*TenderResponse, len(tenders))
|
|
for i := range tenders {
|
|
resp := tenders[i].ToResponseWithLanguage("")
|
|
out[resp.ID] = resp
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// buildDetailResponse maps a tender to API form and enriches translation (MinIO) and summary.
|
|
func (s *tenderService) buildDetailResponse(ctx context.Context, tender *Tender, language string) *TenderResponse {
|
|
resp := tender.ToResponseWithLanguage("")
|
|
s.enrichWithTranslation(ctx, tender, resp, language)
|
|
s.enrichWithAISummary(ctx, tender, resp)
|
|
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.
|
|
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)
|
|
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(),
|
|
})
|
|
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,
|
|
})
|
|
}
|
|
|
|
s.persistAIOverallSummary(enrichCtx, t, summary)
|
|
}
|
|
|
|
// enrichWithTranslation overlays title/description from MinIO when the pipeline has finished
|
|
// for the requested language. Does not call the AI translate API on GET.
|
|
func (s *tenderService) enrichWithTranslation(ctx context.Context, t *Tender, resp *TenderResponse, language string) {
|
|
if t == nil || resp == nil {
|
|
return
|
|
}
|
|
if s.aiSummarizerStorage == nil || strings.TrimSpace(t.ContractFolderID) == "" {
|
|
return
|
|
}
|
|
|
|
targetLanguage := s.resolveDetailLanguage(language)
|
|
enrichCtx, cancel := context.WithTimeout(ctx, aiTranslationEnrichmentTimeout)
|
|
defer cancel()
|
|
|
|
stored, usedNotice, err := s.lookupTranslationForTender(enrichCtx, t, targetLanguage)
|
|
if err != nil {
|
|
s.logger.Debug("Translation not available for tender detail", map[string]interface{}{
|
|
"tender_id": resp.ID,
|
|
"contract_folder_id": t.ContractFolderID,
|
|
"notice_id": t.NoticePublicationID,
|
|
"language": targetLanguage,
|
|
"error": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if strings.TrimSpace(stored.Title) == "" {
|
|
return
|
|
}
|
|
|
|
resp.Title = stored.Title
|
|
resp.Description = stored.Description
|
|
resp.Language = targetLanguage
|
|
if usedNotice != t.NoticePublicationID {
|
|
s.logger.Debug("Translation 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,
|
|
"language": targetLanguage,
|
|
})
|
|
}
|
|
}
|
|
|
|
// lookupTranslationForTender returns stored translation text, trying primary then related notice ids.
|
|
func (s *tenderService) lookupTranslationForTender(ctx context.Context, t *Tender, language string) (ai_summarizer.StoredTranslation, string, error) {
|
|
if s.aiSummarizerStorage == nil {
|
|
return ai_summarizer.StoredTranslation{}, "", fmt.Errorf("AI summarizer storage is not configured")
|
|
}
|
|
candidates := orderedNoticeCandidates(t)
|
|
if len(candidates) == 0 {
|
|
return ai_summarizer.StoredTranslation{}, "", ai_summarizer.ErrObjectNotFound
|
|
}
|
|
|
|
var lastErr error
|
|
for _, noticeID := range candidates {
|
|
stored, err := s.aiSummarizerStorage.GetTranslationFromStorage(ctx, t.ContractFolderID, noticeID, language)
|
|
if err == nil {
|
|
return stored, noticeID, nil
|
|
}
|
|
lastErr = err
|
|
if !errors.Is(err, ai_summarizer.ErrTranslationNotReady) &&
|
|
!errors.Is(err, ai_summarizer.ErrObjectNotFound) &&
|
|
!errors.Is(err, ai_summarizer.ErrNoTranslation) {
|
|
if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) {
|
|
s.logger.Error("MinIO unavailable while fetching translation", map[string]interface{}{
|
|
"tender_id": t.ID.Hex(),
|
|
"contract_folder_id": t.ContractFolderID,
|
|
"notice_id": noticeID,
|
|
"language": language,
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
return ai_summarizer.StoredTranslation{}, "", err
|
|
}
|
|
}
|
|
if lastErr == nil {
|
|
lastErr = ai_summarizer.ErrObjectNotFound
|
|
}
|
|
return ai_summarizer.StoredTranslation{}, "", lastErr
|
|
}
|
|
|
|
// resolveDetailLanguage returns the language code used for detail responses.
|
|
func (s *tenderService) resolveDetailLanguage(language string) string {
|
|
clean := strings.ToLower(strings.TrimSpace(language))
|
|
if clean == "" {
|
|
return s.defaultLanguage
|
|
}
|
|
return clean
|
|
}
|
|
|
|
// persistAIOverallSummary stores the resolved summary on the tender for list/search responses.
|
|
func (s *tenderService) persistAIOverallSummary(ctx context.Context, t *Tender, summary string) {
|
|
summary = strings.TrimSpace(summary)
|
|
if summary == "" || summary == strings.TrimSpace(t.AIOverallSummary) {
|
|
return
|
|
}
|
|
|
|
t.AIOverallSummary = summary
|
|
t.UpdatedAt = time.Now().Unix()
|
|
if err := s.repository.Update(ctx, t); err != nil {
|
|
s.logger.Warn("Failed to persist AI overall summary on tender", map[string]interface{}{
|
|
"tender_id": t.ID.Hex(),
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// lookupSummaryForTender returns the overall_summary for a tender by trying its
|
|
// primary notice publication id first, then each related publication id in
|
|
// reverse (newest-first) order. The notice id that actually served the summary
|
|
// is returned alongside.
|
|
func (s *tenderService) lookupSummaryForTender(ctx context.Context, t *Tender) (string, string, error) {
|
|
if s.aiSummarizerStorage == nil {
|
|
return "", "", fmt.Errorf("AI summarizer storage is not configured")
|
|
}
|
|
candidates := orderedNoticeCandidates(t)
|
|
if len(candidates) == 0 {
|
|
return "", "", ai_summarizer.ErrObjectNotFound
|
|
}
|
|
|
|
var lastErr error
|
|
for _, noticeID := range candidates {
|
|
summary, err := s.aiSummarizerStorage.GetSummaryFromStorage(ctx, t.ContractFolderID, noticeID)
|
|
if err == nil {
|
|
return summary, noticeID, nil
|
|
}
|
|
lastErr = err
|
|
// Only "not found" / "not ready" justify trying the next candidate.
|
|
// Anything else (network/storage error) is propagated immediately.
|
|
if !errors.Is(err, ai_summarizer.ErrObjectNotFound) &&
|
|
!errors.Is(err, ai_summarizer.ErrSummaryNotReady) &&
|
|
!errors.Is(err, ai_summarizer.ErrNoOverallSummary) {
|
|
if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) {
|
|
s.logger.Error("MinIO unavailable while fetching AI summary", map[string]interface{}{
|
|
"tender_id": t.ID,
|
|
"contract_folder_id": t.ContractFolderID,
|
|
"notice_id": noticeID,
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
return "", noticeID, err
|
|
}
|
|
}
|
|
if lastErr == nil {
|
|
lastErr = ai_summarizer.ErrObjectNotFound
|
|
}
|
|
return "", "", lastErr
|
|
}
|
|
|
|
// orderedNoticeCandidates returns the publication ids to try when resolving
|
|
// AI-side artefacts for a (possibly merged) tender, newest-first and deduped.
|
|
// The primary NoticePublicationID is tried first, then any older ids stored in
|
|
// RelatedNoticePublicationIDs (which we append oldest-first; reverse so newest
|
|
// wins).
|
|
func orderedNoticeCandidates(t *Tender) []string {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
seen := make(map[string]struct{}, len(t.RelatedNoticePublicationIDs)+1)
|
|
out := make([]string, 0, len(t.RelatedNoticePublicationIDs)+1)
|
|
add := func(id string) {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
return
|
|
}
|
|
seen[id] = struct{}{}
|
|
out = append(out, id)
|
|
}
|
|
add(t.NoticePublicationID)
|
|
for i := len(t.RelatedNoticePublicationIDs) - 1; i >= 0; i-- {
|
|
add(t.RelatedNoticePublicationIDs[i])
|
|
}
|
|
return out
|
|
}
|
|
|
|
// UpdateTender updates an existing tender
|
|
func (s *tenderService) Update(ctx context.Context, req UpdateTenderRequest) (*TenderResponse, error) {
|
|
s.logger.Info("Updating tender", map[string]interface{}{
|
|
"tender_id": req.ID,
|
|
})
|
|
|
|
// Get existing tender
|
|
tender, err := s.repository.GetByID(ctx, req.ID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to find tender for update", map[string]interface{}{
|
|
"tender_id": req.ID,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to find tender: %w", err)
|
|
}
|
|
|
|
// Update fields if provided
|
|
if req.Title != nil {
|
|
tender.Title = *req.Title
|
|
}
|
|
if req.Description != nil {
|
|
tender.Description = *req.Description
|
|
}
|
|
if req.Status != nil {
|
|
tender.Status = *req.Status
|
|
}
|
|
if req.EstimatedValue != nil {
|
|
tender.EstimatedValue = *req.EstimatedValue
|
|
}
|
|
if req.Currency != nil {
|
|
tender.Currency = *req.Currency
|
|
}
|
|
if req.TenderDeadline != nil {
|
|
tender.TenderDeadline = *req.TenderDeadline
|
|
}
|
|
|
|
if err := s.repository.Update(ctx, tender); err != nil {
|
|
s.logger.Error("Failed to update tender", map[string]interface{}{
|
|
"tender_id": req.ID,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to update tender: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Tender updated successfully", map[string]interface{}{
|
|
"tender_id": tender.ID,
|
|
})
|
|
|
|
return s.buildDetailResponse(ctx, tender, ""), nil
|
|
}
|
|
|
|
// Delete deletes a tender
|
|
func (s *tenderService) Delete(ctx context.Context, id string) error {
|
|
s.logger.Info("Deleting tender", map[string]interface{}{
|
|
"tender_id": id,
|
|
})
|
|
|
|
if err := s.repository.Delete(ctx, id); err != nil {
|
|
s.logger.Error("Failed to delete tender", map[string]interface{}{
|
|
"tender_id": id,
|
|
"error": err.Error(),
|
|
})
|
|
return fmt.Errorf("failed to delete tender: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Tender deleted successfully", map[string]interface{}{
|
|
"tender_id": id,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetDocumentSummaries retrieves document summaries for a specific tender
|
|
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)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get tender for document summaries", map[string]interface{}{
|
|
"tender_id": id,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to get tender: %w", err)
|
|
}
|
|
|
|
if tender == nil {
|
|
return nil, fmt.Errorf("tender not found")
|
|
}
|
|
|
|
// Return document summaries (empty slice if none exist)
|
|
if tender.DocumentSummaries == nil {
|
|
return []DocumentSummary{}, nil
|
|
}
|
|
|
|
s.logger.Info("Retrieved document summaries for tender", map[string]interface{}{
|
|
"tender_id": id,
|
|
"summaries_count": len(tender.DocumentSummaries),
|
|
})
|
|
|
|
return tender.DocumentSummaries, nil
|
|
}
|
|
|
|
// GetDocuments retrieves scraped document metadata for a tender.
|
|
func (s *tenderService) GetDocuments(ctx context.Context, id string) (*TenderDocumentsResponse, error) {
|
|
if id == "" {
|
|
return nil, fmt.Errorf("tender ID cannot be empty")
|
|
}
|
|
|
|
tender, err := s.repository.GetByID(ctx, id)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get tender for documents", map[string]interface{}{
|
|
"tender_id": id,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to get tender: %w", err)
|
|
}
|
|
if tender == nil {
|
|
return nil, fmt.Errorf("tender not found")
|
|
}
|
|
|
|
empty := func() *TenderDocumentsResponse {
|
|
return &TenderDocumentsResponse{
|
|
TenderID: id,
|
|
Documents: []TenderDocumentResponse{},
|
|
}
|
|
}
|
|
|
|
if s.aiSummarizerStorage == nil {
|
|
s.logger.Debug("AI summarizer storage not configured; returning empty tender documents list", map[string]interface{}{
|
|
"tender_id": id,
|
|
})
|
|
return empty(), nil
|
|
}
|
|
|
|
if strings.TrimSpace(tender.ContractFolderID) == "" {
|
|
return empty(), nil
|
|
}
|
|
|
|
storedDocuments, err := s.listDocumentsForTender(ctx, tender)
|
|
if err != nil {
|
|
s.logger.Warn("Listing tender documents failed; returning empty list", map[string]interface{}{
|
|
"tender_id": id,
|
|
"contract_folder_id": tender.ContractFolderID,
|
|
"notice_id": tender.NoticePublicationID,
|
|
"error": err.Error(),
|
|
})
|
|
storedDocuments = nil
|
|
}
|
|
|
|
documents := make([]TenderDocumentResponse, 0, len(storedDocuments))
|
|
for _, doc := range storedDocuments {
|
|
documents = append(documents, TenderDocumentResponse{
|
|
Filename: doc.DocumentName,
|
|
Size: doc.Size,
|
|
LastModified: doc.LastModified,
|
|
DocumentType: doc.DocumentType,
|
|
})
|
|
}
|
|
|
|
s.logger.Info("Retrieved scraped documents for tender", map[string]interface{}{
|
|
"tender_id": id,
|
|
"contract_folder_id": tender.ContractFolderID,
|
|
"notice_id": tender.NoticePublicationID,
|
|
"documents_count": len(documents),
|
|
})
|
|
|
|
return &TenderDocumentsResponse{
|
|
TenderID: id,
|
|
Documents: documents,
|
|
}, nil
|
|
}
|
|
|
|
// listDocumentsForTender unions document listings across every notice tied to a
|
|
// tender (primary + RelatedNoticePublicationIDs), deduplicating by object name.
|
|
// This way, a merged tender (e.g. CN + Result for the same procedure) exposes
|
|
// the documents from every notice that contributed to it.
|
|
func (s *tenderService) listDocumentsForTender(ctx context.Context, t *Tender) ([]ai_summarizer.StoredDocument, error) {
|
|
if s.aiSummarizerStorage == nil {
|
|
return nil, ErrTenderDocumentStorageUnavailable
|
|
}
|
|
|
|
candidates := orderedNoticeCandidates(t)
|
|
if len(candidates) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
seen := make(map[string]struct{})
|
|
var aggregated []ai_summarizer.StoredDocument
|
|
var firstErr error
|
|
for _, noticeID := range candidates {
|
|
docs, err := s.aiSummarizerStorage.ListDocuments(ctx, t.ContractFolderID, noticeID)
|
|
if err != nil {
|
|
// Remember the first error, but keep trying other notices so a
|
|
// missing folder for an older notice does not hide newer docs.
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
continue
|
|
}
|
|
for _, doc := range docs {
|
|
if _, ok := seen[doc.ObjectName]; ok {
|
|
continue
|
|
}
|
|
seen[doc.ObjectName] = struct{}{}
|
|
aggregated = append(aggregated, doc)
|
|
}
|
|
}
|
|
|
|
if len(aggregated) == 0 && firstErr != nil {
|
|
return nil, firstErr
|
|
}
|
|
return aggregated, nil
|
|
}
|
|
|
|
// DownloadDocuments returns a ZIP stream containing all documents for a tender.
|
|
func (s *tenderService) DownloadDocuments(ctx context.Context, id string) (*TenderDocumentDownload, error) {
|
|
if id == "" {
|
|
return nil, fmt.Errorf("tender ID cannot be empty")
|
|
}
|
|
if s.aiSummarizerStorage == nil {
|
|
return nil, ErrTenderDocumentStorageUnavailable
|
|
}
|
|
|
|
tender, err := s.repository.GetByID(ctx, id)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get tender for document download", map[string]interface{}{
|
|
"tender_id": id,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to get tender: %w", err)
|
|
}
|
|
if tender == nil {
|
|
return nil, fmt.Errorf("tender not found")
|
|
}
|
|
if strings.TrimSpace(tender.ContractFolderID) == "" {
|
|
return nil, ErrTenderDocumentNotFound
|
|
}
|
|
if strings.TrimSpace(tender.NoticePublicationID) == "" {
|
|
return nil, ErrTenderDocumentNotFound
|
|
}
|
|
|
|
documents, err := s.listDocumentsForTender(ctx, tender)
|
|
if err != nil {
|
|
s.logger.Error("Failed to list tender documents for archive", map[string]interface{}{
|
|
"tender_id": id,
|
|
"contract_folder_id": tender.ContractFolderID,
|
|
"notice_id": tender.NoticePublicationID,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to list tender documents: %w", err)
|
|
}
|
|
if len(documents) == 0 {
|
|
return nil, ErrTenderDocumentNotFound
|
|
}
|
|
|
|
reader, writer := io.Pipe()
|
|
go s.writeDocumentsArchive(ctx, writer, documents)
|
|
|
|
filename := fmt.Sprintf("%s-documents.zip", tender.NoticePublicationID)
|
|
s.logger.Info("Tender documents archive download prepared", map[string]interface{}{
|
|
"tender_id": id,
|
|
"notice_id": tender.NoticePublicationID,
|
|
"documents_count": len(documents),
|
|
})
|
|
|
|
return &TenderDocumentDownload{
|
|
Reader: reader,
|
|
Filename: filename,
|
|
ContentType: "application/zip",
|
|
}, nil
|
|
}
|
|
|
|
// DownloadDocument streams a single scraped document for a tender.
|
|
func (s *tenderService) DownloadDocument(ctx context.Context, id, filename string) (*TenderDocumentDownload, error) {
|
|
if id == "" {
|
|
return nil, fmt.Errorf("tender ID cannot be empty")
|
|
}
|
|
if strings.TrimSpace(filename) == "" {
|
|
return nil, fmt.Errorf("document filename cannot be empty")
|
|
}
|
|
if s.aiSummarizerStorage == nil {
|
|
return nil, ErrTenderDocumentStorageUnavailable
|
|
}
|
|
|
|
tender, err := s.repository.GetByID(ctx, id)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get tender for document download", map[string]interface{}{
|
|
"tender_id": id,
|
|
"filename": filename,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to get tender: %w", err)
|
|
}
|
|
if tender == nil {
|
|
return nil, fmt.Errorf("tender not found")
|
|
}
|
|
if strings.TrimSpace(tender.ContractFolderID) == "" {
|
|
return nil, ErrTenderDocumentNotFound
|
|
}
|
|
|
|
documents, err := s.listDocumentsForTender(ctx, tender)
|
|
if err != nil {
|
|
s.logger.Error("Failed to list tender documents for download", map[string]interface{}{
|
|
"tender_id": id,
|
|
"filename": filename,
|
|
"contract_folder_id": tender.ContractFolderID,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to list tender documents: %w", err)
|
|
}
|
|
|
|
doc, ok := findStoredDocumentByFilename(documents, filename)
|
|
if !ok {
|
|
return nil, ErrTenderDocumentNotFound
|
|
}
|
|
|
|
reader, err := s.aiSummarizerStorage.DownloadDocument(ctx, doc.ObjectName)
|
|
if err != nil {
|
|
s.logger.Error("Failed to open tender document from storage", map[string]interface{}{
|
|
"tender_id": id,
|
|
"filename": filename,
|
|
"object_name": doc.ObjectName,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to download document: %w", err)
|
|
}
|
|
|
|
downloadName := path.Base(doc.DocumentName)
|
|
contentType := mime.TypeByExtension(filepath.Ext(downloadName))
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
|
|
s.logger.Info("Tender document download prepared", map[string]interface{}{
|
|
"tender_id": id,
|
|
"filename": downloadName,
|
|
"size": doc.Size,
|
|
})
|
|
|
|
return &TenderDocumentDownload{
|
|
Reader: reader,
|
|
Filename: downloadName,
|
|
ContentType: contentType,
|
|
Size: doc.Size,
|
|
}, nil
|
|
}
|
|
|
|
func findStoredDocumentByFilename(documents []ai_summarizer.StoredDocument, filename string) (ai_summarizer.StoredDocument, bool) {
|
|
filename = strings.TrimSpace(filename)
|
|
if filename == "" {
|
|
return ai_summarizer.StoredDocument{}, false
|
|
}
|
|
|
|
wantBase := path.Base(filename)
|
|
for _, doc := range documents {
|
|
if doc.DocumentName == filename || path.Base(doc.DocumentName) == wantBase {
|
|
return doc, true
|
|
}
|
|
}
|
|
return ai_summarizer.StoredDocument{}, false
|
|
}
|
|
|
|
func (s *tenderService) writeDocumentsArchive(ctx context.Context, writer *io.PipeWriter, documents []ai_summarizer.StoredDocument) {
|
|
zipWriter := zip.NewWriter(writer)
|
|
usedNames := make(map[string]int, len(documents))
|
|
|
|
for _, doc := range documents {
|
|
select {
|
|
case <-ctx.Done():
|
|
zipWriter.Close()
|
|
writer.CloseWithError(ctx.Err())
|
|
return
|
|
default:
|
|
}
|
|
|
|
if err := s.addDocumentToArchive(ctx, zipWriter, usedNames, doc); err != nil {
|
|
zipWriter.Close()
|
|
writer.CloseWithError(err)
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := zipWriter.Close(); err != nil {
|
|
writer.CloseWithError(err)
|
|
return
|
|
}
|
|
writer.Close()
|
|
}
|
|
|
|
func (s *tenderService) addDocumentToArchive(ctx context.Context, zipWriter *zip.Writer, usedNames map[string]int, doc ai_summarizer.StoredDocument) error {
|
|
reader, err := s.aiSummarizerStorage.DownloadDocument(ctx, doc.ObjectName)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open document %s: %w", doc.DocumentName, err)
|
|
}
|
|
defer reader.Close()
|
|
|
|
entry, err := zipWriter.Create(uniqueArchiveName(usedNames, doc.DocumentName))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create archive entry for %s: %w", doc.DocumentName, err)
|
|
}
|
|
|
|
if _, err := io.Copy(entry, reader); err != nil {
|
|
return fmt.Errorf("failed to write document %s to archive: %w", doc.DocumentName, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func uniqueArchiveName(usedNames map[string]int, filename string) string {
|
|
cleanName := path.Base(filename)
|
|
if cleanName == "." || cleanName == "/" || cleanName == "" {
|
|
cleanName = "document"
|
|
}
|
|
|
|
count := usedNames[cleanName]
|
|
usedNames[cleanName] = count + 1
|
|
if count == 0 {
|
|
return cleanName
|
|
}
|
|
|
|
extension := filepath.Ext(cleanName)
|
|
base := strings.TrimSuffix(cleanName, extension)
|
|
return fmt.Sprintf("%s-%d%s", base, count+1, extension)
|
|
}
|
|
|
|
// ListTenders retrieves tenders with pagination and filtering
|
|
func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) {
|
|
s.logger.Info("Listing tenders", map[string]interface{}{
|
|
"limit": pagination.Limit,
|
|
"offset": pagination.Offset,
|
|
"company_id": form.CompanyID,
|
|
"customer_id": form.CustomerID,
|
|
"from": form.DeadlineFrom,
|
|
"to": form.DeadlineTo,
|
|
})
|
|
|
|
// If customer ID is provided, get customer keywords and add to search
|
|
if form.CustomerID != nil && *form.CustomerID != "" {
|
|
customerResp, err := s.customerService.GetByID(ctx, *form.CustomerID)
|
|
if err == nil && customerResp != nil {
|
|
// Only use keywords if status is ready
|
|
if customerResp.KeywordsStatus == "ready" && len(customerResp.Keywords) > 0 {
|
|
// Combine customer keywords with existing search query
|
|
keywordsQuery := strings.Join(customerResp.Keywords, " ")
|
|
if form.Search != nil && *form.Search != "" {
|
|
combinedSearch := *form.Search + " " + keywordsQuery
|
|
form.Search = &combinedSearch
|
|
} else {
|
|
form.Search = &keywordsQuery
|
|
}
|
|
s.logger.Info("Using customer keywords for search", map[string]interface{}{
|
|
"customer_id": *form.CustomerID,
|
|
"keywords": customerResp.Keywords,
|
|
})
|
|
} else {
|
|
s.logger.Info("Customer keywords not ready, skipping keyword-based search", map[string]interface{}{
|
|
"customer_id": *form.CustomerID,
|
|
"keywords_status": customerResp.KeywordsStatus,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
page, err := s.repository.Search(ctx, form, pagination)
|
|
if err != nil {
|
|
s.logger.Error("Failed to list tenders", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to list tenders: %w", err)
|
|
}
|
|
|
|
// If no company ID provided, return tenders without match percentage
|
|
if form.CompanyID == nil || *form.CompanyID == "" {
|
|
tenderResponses := make([]TenderResponse, 0, len(page.Items))
|
|
for _, tender := range page.Items {
|
|
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
|
|
}
|
|
|
|
return &SearchResponse{
|
|
Tenders: tenderResponses,
|
|
Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
|
|
}, nil
|
|
}
|
|
|
|
// Get company CPV codes for match calculation
|
|
comp, err := s.companyService.GetByID(ctx, *form.CompanyID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get company for match calculation", map[string]interface{}{
|
|
"company_id": *form.CompanyID,
|
|
"error": err.Error(),
|
|
})
|
|
// Continue without match percentage if company not found
|
|
tenderResponses := make([]TenderResponse, 0, len(page.Items))
|
|
for _, tender := range page.Items {
|
|
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
|
|
}
|
|
|
|
return &SearchResponse{
|
|
Tenders: tenderResponses,
|
|
Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
|
|
},
|
|
nil
|
|
}
|
|
|
|
var companyCPVCodes []string
|
|
if comp.Tags != nil && comp.Tags.CPVCodes != nil {
|
|
companyCPVCodes = comp.Tags.CPVCodes
|
|
}
|
|
|
|
s.logger.Info("Tenders sorted by match percentage", map[string]interface{}{
|
|
"company_id": *form.CompanyID,
|
|
"company_cpv_codes": len(companyCPVCodes),
|
|
"total_tenders": len(page.Items),
|
|
})
|
|
|
|
tenderResponses := make([]TenderResponse, 0, len(page.Items))
|
|
for _, tender := range page.Items {
|
|
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
|
|
}
|
|
|
|
return &SearchResponse{
|
|
Tenders: tenderResponses,
|
|
Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
|
|
},
|
|
nil
|
|
}
|
|
|
|
// Recommend retrieves tenders with pagination and filtering
|
|
func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) {
|
|
s.logger.Info("Recommend tenders", map[string]interface{}{
|
|
"company_id": form.CompanyID,
|
|
"customer_id": form.CustomerID,
|
|
})
|
|
|
|
form.Status = []string{string(TenderStatusActive)}
|
|
|
|
// Priority: Use customer keywords if available, otherwise use company CPV codes
|
|
if form.CustomerID != nil && *form.CustomerID != "" {
|
|
customerResp, err := s.customerService.GetByID(ctx, *form.CustomerID)
|
|
if err == nil && customerResp != nil {
|
|
// Only use keywords if status is ready
|
|
if customerResp.KeywordsStatus == "ready" && len(customerResp.Keywords) > 0 {
|
|
// Use customer keywords for recommendation
|
|
keywordsQuery := strings.Join(customerResp.Keywords, " ")
|
|
form.Search = &keywordsQuery
|
|
s.logger.Info("Using customer keywords for recommendation", map[string]interface{}{
|
|
"customer_id": *form.CustomerID,
|
|
"keywords": customerResp.Keywords,
|
|
})
|
|
} else {
|
|
s.logger.Info("Customer keywords not ready, falling back to company CPV codes", map[string]interface{}{
|
|
"customer_id": *form.CustomerID,
|
|
"keywords_status": customerResp.KeywordsStatus,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback to company CPV codes if no customer keywords
|
|
if form.Search == nil || *form.Search == "" {
|
|
if form.CompanyID != nil && *form.CompanyID != "" {
|
|
company, err := s.companyService.GetByID(ctx, *form.CompanyID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get company for CPV codes, skipping CPV-based filtering", map[string]interface{}{
|
|
"company_id": form.CompanyID,
|
|
"error": err.Error(),
|
|
})
|
|
// Continue without CPV-based filtering instead of failing
|
|
} else if company.Tags != nil && len(company.Tags.CPVCodes) > 0 {
|
|
form.Classifications = company.Tags.CPVCodes
|
|
}
|
|
}
|
|
}
|
|
|
|
page, err := s.repository.Search(ctx, form, pagination)
|
|
if err != nil {
|
|
s.logger.Error("Failed to list tenders", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to list tenders: %w", err)
|
|
}
|
|
|
|
tenderResponses := make([]TenderResponse, 0, len(page.Items))
|
|
for _, tender := range page.Items {
|
|
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
|
|
}
|
|
|
|
return &SearchResponse{
|
|
Tenders: tenderResponses,
|
|
Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
|
|
}, nil
|
|
}
|
|
|
|
// Delete deletes a tender
|
|
|
|
// GetTenderStatistics retrieves tender statistics
|
|
func (s *tenderService) GetTenderStatistics(ctx context.Context) (*TenderStatistics, error) {
|
|
s.logger.Info("Retrieving tender statistics", map[string]interface{}{})
|
|
|
|
stats, err := s.repository.GetStatistics(ctx)
|
|
if err != nil {
|
|
s.logger.Error("Failed to retrieve tender statistics", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to retrieve statistics: %w", err)
|
|
}
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
// UpdateExpiredTenders updates status of expired tenders
|
|
func (s *tenderService) UpdateExpiredTenders(ctx context.Context) error {
|
|
s.logger.Info("Updating expired tenders", map[string]interface{}{})
|
|
|
|
// Get tenders that should be expired
|
|
expiredTenders, err := s.repository.GetExpiredTenders(ctx, time.Now().Unix())
|
|
if err != nil {
|
|
s.logger.Error("Failed to get expired tenders", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
return fmt.Errorf("failed to get expired tenders: %w", err)
|
|
}
|
|
|
|
updatedCount := 0
|
|
for _, tender := range expiredTenders {
|
|
if tender.Status != TenderStatusExpired {
|
|
tender.UpdateStatus(TenderStatusExpired)
|
|
if err := s.repository.Update(ctx, &tender); err != nil {
|
|
s.logger.Error("Failed to update expired tender", map[string]interface{}{
|
|
"tender_id": tender.ID,
|
|
"error": err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
updatedCount++
|
|
}
|
|
}
|
|
|
|
s.logger.Info("Expired tenders updated", map[string]interface{}{
|
|
"total_expired": len(expiredTenders),
|
|
"updated": updatedCount,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// CleanupOldData removes old data based on retention policy
|
|
func (s *tenderService) CleanupOldData(ctx context.Context, olderThan time.Duration, pagination *response.Pagination) error {
|
|
s.logger.Info("Cleaning up old data", map[string]interface{}{
|
|
"older_than": olderThan.String(),
|
|
"limit": pagination.Limit,
|
|
"offset": pagination.Offset,
|
|
})
|
|
|
|
cutoffTime := time.Now().Add(-olderThan).Unix()
|
|
|
|
// For now, we'll just log what would be cleaned up
|
|
// In production, you might want to actually delete old data
|
|
form := &SearchForm{
|
|
Status: []string{string(TenderStatusExpired), string(TenderStatusCancelled), string(TenderStatusAwarded)},
|
|
}
|
|
|
|
oldPage, err := s.repository.Search(ctx, form, pagination)
|
|
if err != nil {
|
|
s.logger.Error("Failed to find old tenders for cleanup", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
return fmt.Errorf("failed to find old tenders: %w", err)
|
|
}
|
|
|
|
cleanupCount := 0
|
|
for _, tender := range oldPage.Items {
|
|
if tender.CreatedAt < cutoffTime {
|
|
cleanupCount++
|
|
// Here you would delete the tender if implementing actual cleanup
|
|
// s.repository.Delete(ctx, tender.ID)
|
|
}
|
|
}
|
|
|
|
s.logger.Info("Old data cleanup completed", map[string]interface{}{
|
|
"found_old_tenders": cleanupCount,
|
|
"cutoff_time": cutoffTime,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
// 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 == "" {
|
|
return nil, fmt.Errorf("tender ID cannot be empty")
|
|
}
|
|
|
|
t, err := s.repository.GetByID(ctx, id)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get tender for AI summary", 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")
|
|
}
|
|
|
|
noticeID := strings.TrimSpace(t.NoticePublicationID)
|
|
emptyResp := func(source string) *AISummaryResponse {
|
|
return &AISummaryResponse{
|
|
NoticeID: noticeID,
|
|
OverallSummary: "",
|
|
Source: source,
|
|
}
|
|
}
|
|
|
|
if noticeID == "" || strings.TrimSpace(t.ContractFolderID) == "" {
|
|
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,
|
|
})
|
|
return emptyResp("unavailable"), nil
|
|
}
|
|
|
|
summary, usedNotice, err := s.lookupSummaryForTender(ctx, t)
|
|
if err == nil {
|
|
return &AISummaryResponse{
|
|
NoticeID: usedNotice,
|
|
OverallSummary: summary,
|
|
Source: "storage",
|
|
}, nil
|
|
}
|
|
|
|
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 errors.Is(err, ai_summarizer.ErrSummaryNotReady) {
|
|
return emptyResp("pending"), nil
|
|
}
|
|
if errors.Is(err, ai_summarizer.ErrObjectNotFound) || errors.Is(err, ai_summarizer.ErrNoOverallSummary) {
|
|
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,
|
|
"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)
|
|
}
|
|
|
|
// TriggerAISummarize triggers on-demand AI summarization for a tender.
|
|
// It calls the external AI service HTTP API to generate summaries.
|
|
func (s *tenderService) TriggerAISummarize(ctx context.Context, id string) (*AISummarizeResponse, 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")
|
|
}
|
|
|
|
// 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{}{
|
|
"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")
|
|
}
|
|
|
|
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,
|
|
})
|
|
|
|
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)
|
|
if err != nil {
|
|
s.logger.Error("On-demand AI summarization 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 summarization request failed: %w", err)
|
|
}
|
|
|
|
// Map the response to our response type
|
|
summaries := make([]AIDocumentSummary, 0, len(resp.Summaries))
|
|
for _, s := range resp.Summaries {
|
|
summaries = append(summaries, AIDocumentSummary{
|
|
DocumentName: s.DocumentName,
|
|
DocumentType: s.DocumentType,
|
|
Summary: s.Summary,
|
|
SummaryModel: s.SummaryModel,
|
|
Error: s.Error,
|
|
})
|
|
}
|
|
|
|
s.logger.Info("On-demand AI summarization completed", map[string]interface{}{
|
|
"tender_id": id,
|
|
"contract_folder_id": t.ContractFolderID,
|
|
"notice_publication_id": t.NoticePublicationID,
|
|
"summaries_count": len(summaries),
|
|
})
|
|
|
|
noticePublicationID := resp.ResolvedNoticePublicationID()
|
|
if noticePublicationID == "" {
|
|
noticePublicationID = t.NoticePublicationID
|
|
}
|
|
contractFolderID := strings.TrimSpace(resp.ContractFolderID)
|
|
if contractFolderID == "" {
|
|
contractFolderID = t.ContractFolderID
|
|
}
|
|
|
|
return &AISummarizeResponse{
|
|
ContractFolderID: contractFolderID,
|
|
NoticePublicationID: noticePublicationID,
|
|
Summaries: summaries,
|
|
OverallSummary: resp.OverallSummary,
|
|
}, 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.
|
|
func (s *tenderService) GetAllAISummaries(ctx context.Context) ([]ai_summarizer.TenderSummaryItem, error) {
|
|
|
|
s.logger.Info("Retrieving all AI summaries from storage", map[string]interface{}{})
|
|
|
|
// We need to access the underlying StorageClient to call GetAllSummaries.
|
|
// Since aiSummarizerStorage is an interface, we type-assert to access GetAllSummaries.
|
|
type allSummaries interface {
|
|
GetAllSummaries(ctx context.Context) ([]ai_summarizer.TenderSummaryItem, error)
|
|
}
|
|
|
|
storage, ok := s.aiSummarizerStorage.(allSummaries)
|
|
if !ok {
|
|
return nil, fmt.Errorf("AI summarizer storage does not support batch retrieval")
|
|
}
|
|
|
|
results, err := storage.GetAllSummaries(ctx)
|
|
if err != nil {
|
|
fields := map[string]interface{}{
|
|
"error": err.Error(),
|
|
}
|
|
if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) {
|
|
s.logger.Error("Failed to list AI summaries: MinIO connection unavailable", fields)
|
|
return nil, fmt.Errorf("failed to get all AI summaries: MinIO connection unavailable: %w", err)
|
|
}
|
|
s.logger.Error("Failed to get all AI summaries from MinIO", fields)
|
|
return nil, fmt.Errorf("failed to get all AI summaries: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Retrieved all AI summaries", map[string]interface{}{
|
|
"total_count": len(results),
|
|
})
|
|
|
|
return results, nil
|
|
}
|
|
|
|
// ListTendersWithScrapedDocuments lists database tenders whose contract_folder_id has
|
|
// documents in MinIO. By default uses counts from the initial MinIO procedure scan only (fast).
|
|
// Set syncMongoMetadata=true to list every notice folder again and persist ScrapedDocuments to Mongo (slow).
|
|
func (s *tenderService) ListTendersWithScrapedDocuments(ctx context.Context, pagination *response.Pagination, syncMongoMetadata bool) (*TendersWithScrapedDocumentsResponse, error) {
|
|
if s.aiSummarizerStorage == nil {
|
|
return nil, fmt.Errorf("document storage is not configured")
|
|
}
|
|
|
|
type procedureLister interface {
|
|
ListProceduresWithDocuments(ctx context.Context) ([]ai_summarizer.ProcedureDocumentsSummary, error)
|
|
}
|
|
storage, ok := s.aiSummarizerStorage.(procedureLister)
|
|
if !ok {
|
|
return nil, fmt.Errorf("document storage does not support procedure listing")
|
|
}
|
|
|
|
s.logger.Info("Listing tenders with scraped documents from MinIO", map[string]interface{}{
|
|
"limit": pagination.Limit,
|
|
"offset": pagination.Offset,
|
|
"sync_mongo_metadata": syncMongoMetadata,
|
|
})
|
|
|
|
procedures, err := storage.ListProceduresWithDocuments(ctx)
|
|
if err != nil {
|
|
fields := map[string]interface{}{"error": err.Error()}
|
|
if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) {
|
|
s.logger.Error("Failed to list scraped documents: MinIO unavailable", fields)
|
|
return nil, fmt.Errorf("failed to list tenders with scraped documents: MinIO connection unavailable: %w", err)
|
|
}
|
|
s.logger.Error("Failed to list procedure documents from MinIO", fields)
|
|
return nil, fmt.Errorf("failed to list tenders with scraped documents: %w", err)
|
|
}
|
|
|
|
folderIDs := make([]string, 0, len(procedures))
|
|
for _, proc := range procedures {
|
|
folderIDs = append(folderIDs, proc.ContractFolderID)
|
|
}
|
|
|
|
tenderByFolder, err := s.repository.GetLatestByContractFolderIDs(ctx, folderIDs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load tenders by contract_folder_id: %w", err)
|
|
}
|
|
|
|
matched := make([]TenderScrapedDocumentsItem, 0, len(procedures))
|
|
for _, proc := range procedures {
|
|
if proc.DocumentCount <= 0 {
|
|
continue
|
|
}
|
|
tender, ok := tenderByFolder[strings.TrimSpace(proc.ContractFolderID)]
|
|
if tender == nil || !ok {
|
|
continue
|
|
}
|
|
|
|
item := tender.ToTenderScrapedDocumentsItem()
|
|
item.DocumentCount = proc.DocumentCount
|
|
item.DocumentsScrapedAt = proc.LatestModified
|
|
|
|
if syncMongoMetadata {
|
|
docs, listErr := s.listDocumentsForTender(ctx, tender)
|
|
if listErr != nil {
|
|
if errors.Is(listErr, ai_summarizer.ErrMinIOUnavailable) {
|
|
return nil, fmt.Errorf("failed to list tenders with scraped documents: MinIO connection unavailable: %w", listErr)
|
|
}
|
|
s.logger.Debug("Skipping tender: could not list documents from MinIO", map[string]interface{}{
|
|
"tender_id": tender.ID.Hex(),
|
|
"contract_folder_id": proc.ContractFolderID,
|
|
"error": listErr.Error(),
|
|
})
|
|
continue
|
|
}
|
|
if len(docs) == 0 {
|
|
continue
|
|
}
|
|
s.persistScrapedDocuments(ctx, tender, docs)
|
|
item.DocumentCount = len(docs)
|
|
if tender.ProcessingMetadata.DocumentsScrapedAt != 0 {
|
|
item.DocumentsScrapedAt = tender.ProcessingMetadata.DocumentsScrapedAt
|
|
}
|
|
}
|
|
|
|
matched = append(matched, item)
|
|
}
|
|
|
|
total := int64(len(matched))
|
|
start := pagination.Offset
|
|
if start > len(matched) {
|
|
start = len(matched)
|
|
}
|
|
end := start + pagination.Limit
|
|
if end > len(matched) {
|
|
end = len(matched)
|
|
}
|
|
page := matched[start:end]
|
|
hasMore := int64(end) < total
|
|
|
|
s.logger.Info("Listed tenders with scraped documents", map[string]interface{}{
|
|
"count": len(page),
|
|
"total_count": total,
|
|
})
|
|
|
|
return &TendersWithScrapedDocumentsResponse{
|
|
Tenders: page,
|
|
Metadata: pagination.ListMeta(total, "", hasMore, pagination.Offset),
|
|
}, nil
|
|
}
|
|
|
|
// persistScrapedDocuments updates the tender when MinIO has scraped documents for its procedure.
|
|
func (s *tenderService) persistScrapedDocuments(ctx context.Context, t *Tender, docs []ai_summarizer.StoredDocument) {
|
|
if t == nil || len(docs) == 0 {
|
|
return
|
|
}
|
|
|
|
bucket := ""
|
|
if named, ok := s.aiSummarizerStorage.(interface{ BucketName() string }); ok {
|
|
bucket = named.BucketName()
|
|
}
|
|
|
|
scraped := make([]ScrapedDocument, len(docs))
|
|
var latestModified int64
|
|
for i, doc := range docs {
|
|
scraped[i] = ScrapedDocument{
|
|
Filename: doc.DocumentName,
|
|
ObjectName: doc.ObjectName,
|
|
BucketName: bucket,
|
|
Size: doc.Size,
|
|
ScrapedAt: doc.LastModified,
|
|
}
|
|
if doc.LastModified > latestModified {
|
|
latestModified = doc.LastModified
|
|
}
|
|
}
|
|
|
|
unchanged := t.ProcessingMetadata.DocumentsScraped &&
|
|
len(t.ScrapedDocuments) == len(scraped)
|
|
if unchanged {
|
|
return
|
|
}
|
|
|
|
t.ProcessingMetadata.DocumentsScraped = true
|
|
if t.ProcessingMetadata.DocumentsScrapedAt == 0 {
|
|
if latestModified > 0 {
|
|
t.ProcessingMetadata.DocumentsScrapedAt = latestModified
|
|
} else {
|
|
t.ProcessingMetadata.DocumentsScrapedAt = time.Now().Unix()
|
|
}
|
|
}
|
|
t.ScrapedDocuments = scraped
|
|
t.UpdatedAt = time.Now().Unix()
|
|
|
|
if err := s.repository.Update(ctx, t); err != nil {
|
|
s.logger.Warn("Failed to persist scraped documents on tender", map[string]interface{}{
|
|
"tender_id": t.ID.Hex(),
|
|
"contract_folder_id": t.ContractFolderID,
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// TriggerAITranslate triggers on-demand AI translation for a tender. Results are
|
|
// read from MinIO when available, otherwise fetched from the AI service (which persists to MinIO).
|
|
func (s *tenderService) TriggerAITranslate(ctx context.Context, id, language string) (*AITranslateResponse, 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")
|
|
}
|
|
|
|
targetLanguage := strings.ToLower(strings.TrimSpace(language))
|
|
if targetLanguage == "" {
|
|
targetLanguage = s.defaultLanguage
|
|
}
|
|
|
|
t, err := s.repository.GetByID(ctx, id)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get tender for AI translation", 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")
|
|
}
|
|
|
|
title, description, fromAPI, err := s.resolveTranslation(ctx, t, targetLanguage, ai_summarizer.TranslationRequestSourceManualTrigger)
|
|
if err != nil {
|
|
s.logger.Error("AI translation failed", map[string]interface{}{
|
|
"tender_id": id,
|
|
"notice_id": t.NoticePublicationID,
|
|
"language": targetLanguage,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("AI translation request failed: %w", err)
|
|
}
|
|
|
|
if fromAPI {
|
|
s.logger.Info("Manual AI translation request succeeded", map[string]interface{}{
|
|
"tender_id": id,
|
|
"notice_id": t.NoticePublicationID,
|
|
"language": targetLanguage,
|
|
})
|
|
}
|
|
|
|
return s.buildAITranslateResponse(t, targetLanguage, title, description), nil
|
|
}
|
|
|
|
func (s *tenderService) buildAITranslateResponse(t *Tender, language, title, description string) *AITranslateResponse {
|
|
return &AITranslateResponse{
|
|
ContractFolderID: t.ContractFolderID,
|
|
NoticePublicationID: t.NoticePublicationID,
|
|
Language: language,
|
|
TranslatedTitle: title,
|
|
TranslatedDescription: description,
|
|
}
|
|
}
|
|
|
|
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 {
|
|
return stored.Title, stored.Description, false, nil
|
|
}
|
|
if !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{}{
|
|
"tender_id": t.ID.Hex(),
|
|
"language": language,
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (s *tenderService) pickResponseLanguage(language *string) string {
|
|
if language != nil {
|
|
clean := strings.ToLower(strings.TrimSpace(*language))
|
|
if clean != "" {
|
|
return clean
|
|
}
|
|
}
|
|
return s.defaultLanguage
|
|
}
|