Integrated AI summary

This commit is contained in:
Mazyar
2026-05-05 17:47:31 +03:30
parent ddbf1ca3d5
commit 65a19d1514
12 changed files with 989 additions and 11 deletions
+24
View File
@@ -65,6 +65,7 @@ type TenderResponse struct {
Status TenderStatus `json:"status"`
TenderID string `json:"tender_id"`
CreatedAt int64 `json:"created_at"`
OverallSummary string `json:"overall_summary,omitempty"` // AI-generated summary from the pipeline
}
type OrganizationResponse struct {
@@ -103,3 +104,26 @@ func (t *Tender) ToResponse() *TenderResponse {
return response
}
// AISummaryResponse represents the response for getting an AI-generated summary
type AISummaryResponse struct {
NoticeID string `json:"notice_id"`
OverallSummary string `json:"overall_summary"`
Source string `json:"source"` // "storage" or "on_demand"
}
// AISummarizeResponse represents the response for triggering on-demand AI summarization
type AISummarizeResponse struct {
NoticeID string `json:"notice_id"`
Summaries []AIDocumentSummary `json:"summaries"`
OverallSummary *string `json:"overall_summary"`
}
// AIDocumentSummary represents a single document summary from the AI service
type AIDocumentSummary struct {
DocumentName string `json:"document_name"`
DocumentType string `json:"document_type"`
Summary string `json:"summary"`
SummaryModel string `json:"summary_model"`
Error string `json:"error,omitempty"`
}
+111
View File
@@ -360,3 +360,114 @@ func (h *TenderHandler) GetDocumentSummaries(c echo.Context) error {
return response.Success(c, summaries, "Document summaries retrieved successfully")
}
// GetAISummary retrieves the AI-generated overall summary for a tender
// @Summary Get AI-generated tender summary
// @Description Retrieve the AI-generated overall summary for a tender from the AI pipeline storage
// @Tags Admin-Tenders
// @Produce json
// @Param id path string true "Tender ID"
// @Success 200 {object} response.APIResponse{data=AISummaryResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/v1/tenders/{id}/ai-summary [get]
// @Security BearerAuth
func (h *TenderHandler) GetAISummary(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
}
summary, err := h.service.GetAISummary(c.Request().Context(), id)
if err != nil {
h.logger.Error("Failed to get AI summary", map[string]interface{}{
"tender_id": id,
"error": err.Error(),
})
return response.NotFound(c, err.Error())
}
return response.Success(c, summary, "AI summary retrieved successfully")
}
// TriggerAISummarize triggers on-demand AI summarization for a tender
// @Summary Trigger AI summarization
// @Description Trigger on-demand AI summarization for a tender by calling the external AI service
// @Tags Admin-Tenders
// @Produce json
// @Param id path string true "Tender ID"
// @Success 200 {object} response.APIResponse{data=AISummarizeResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/v1/tenders/{id}/ai-summarize [post]
// @Security BearerAuth
func (h *TenderHandler) TriggerAISummarize(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.TriggerAISummarize(c.Request().Context(), id)
if err != nil {
h.logger.Error("Failed to trigger AI summarization", map[string]interface{}{
"tender_id": id,
"error": err.Error(),
})
return response.InternalServerError(c, err.Error())
}
return response.Success(c, result, "AI summarization completed successfully")
}
// GetPublicAISummary retrieves the AI-generated overall summary for a tender (public endpoint)
// @Summary Get AI-generated tender summary (public)
// @Description Retrieve the AI-generated overall summary for a tender from the AI pipeline storage
// @Tags Tenders
// @Produce json
// @Param id path string true "Tender ID"
// @Success 200 {object} response.APIResponse{data=AISummaryResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /api/v1/tenders/{id}/ai-summary [get]
// @Security BearerAuth
func (h *TenderHandler) GetPublicAISummary(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
}
summary, err := h.service.GetAISummary(c.Request().Context(), id)
if err != nil {
h.logger.Error("Failed to get AI summary", map[string]interface{}{
"tender_id": id,
"error": err.Error(),
})
return response.NotFound(c, err.Error())
}
return response.Success(c, summary, "AI summary retrieved successfully")
}
// GetAllAISummaries retrieves all AI-generated summaries from storage
// @Summary Get all AI-generated tender summaries
// @Description Retrieve all AI-generated summaries from the AI pipeline storage, including pending and completed ones
// @Tags Admin-Tenders
// @Produce json
// @Success 200 {object} response.APIResponse{data=[]ai_summarizer.TenderSummaryItem}
// @Failure 500 {object} response.APIResponse
// @Router /admin/v1/tenders/ai-summaries [get]
// @Security BearerAuth
func (h *TenderHandler) GetAllAISummaries(c echo.Context) error {
summaries, err := h.service.GetAllAISummaries(c.Request().Context())
if err != nil {
h.logger.Error("Failed to get all AI summaries", map[string]interface{}{
"error": err.Error(),
})
return response.InternalServerError(c, err.Error())
}
return response.Success(c, summaries, "AI summaries retrieved successfully")
}
+244 -10
View File
@@ -2,15 +2,27 @@ package tender
import (
"context"
"errors"
"fmt"
"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)
}
// AISummarizerStorage defines the interface for fetching summaries from MinIO storage
type AISummarizerStorage interface {
GetSummaryFromStorage(ctx context.Context, noticeID string) (string, error)
}
// TenderService interface defines tender business logic operations
type Service interface {
// Tender operations
@@ -21,6 +33,11 @@ type Service interface {
Recommend(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error)
GetDocumentSummaries(ctx context.Context, id string) ([]DocumentSummary, error)
// AI summarization operations
GetAISummary(ctx context.Context, id string) (*AISummaryResponse, error)
TriggerAISummarize(ctx context.Context, id string) (*AISummarizeResponse, error)
GetAllAISummaries(ctx context.Context) ([]ai_summarizer.TenderSummaryItem, error)
// Maintenance operations
UpdateExpiredTenders(ctx context.Context) error
CleanupOldData(ctx context.Context, olderThan time.Duration, pagination *response.Pagination) error
@@ -28,19 +45,23 @@ type Service interface {
// tenderService implements TenderService interface
type tenderService struct {
repository TenderRepository
companyService company.Service
customerService customer.Service
logger logger.Logger
repository TenderRepository
companyService company.Service
customerService customer.Service
logger logger.Logger
aiSummarizerClient AISummarizerClient
aiSummarizerStorage AISummarizerStorage
}
// NewService creates a new tender service
func NewService(repository TenderRepository, companyService company.Service, customerService customer.Service, logger logger.Logger) Service {
func NewService(repository TenderRepository, companyService company.Service, customerService customer.Service, logger logger.Logger, aiClient AISummarizerClient, aiStorage AISummarizerStorage) Service {
return &tenderService{
repository: repository,
companyService: companyService,
customerService: customerService,
logger: logger,
repository: repository,
companyService: companyService,
customerService: customerService,
logger: logger,
aiSummarizerClient: aiClient,
aiSummarizerStorage: aiStorage,
}
}
@@ -59,7 +80,33 @@ func (s *tenderService) GetByID(ctx context.Context, id string) (*TenderResponse
return nil, fmt.Errorf("failed to retrieve tender: %w", err)
}
return tender.ToResponse(), nil
resp := tender.ToResponse()
// Enrich with AI summary from MinIO storage (best-effort, non-blocking)
s.enrichWithAISummary(ctx, resp)
return resp, nil
}
// enrichWithAISummary attempts to fetch the AI-generated overall summary from
// MinIO storage and attaches it to the response. Failures are logged but do not
// cause the request to fail — the field will simply be omitted.
func (s *tenderService) enrichWithAISummary(ctx context.Context, resp *TenderResponse) {
if s.aiSummarizerStorage == nil || resp.NoticePublicationID == "" {
return
}
summary, err := s.aiSummarizerStorage.GetSummaryFromStorage(ctx, resp.NoticePublicationID)
if err != nil {
s.logger.Debug("AI summary not available for tender", map[string]interface{}{
"tender_id": resp.ID,
"notice_id": resp.NoticePublicationID,
"error": err.Error(),
})
return
}
resp.OverallSummary = summary
}
// UpdateTender updates an existing tender
@@ -428,3 +475,190 @@ func (s *tenderService) CleanupOldData(ctx context.Context, olderThan time.Durat
return nil
}
// GetAISummary retrieves the AI-generated summary for a tender.
// It first tries to fetch from MinIO storage (async pipeline result).
// If not found or not ready, it returns an appropriate error.
func (s *tenderService) GetAISummary(ctx context.Context, id string) (*AISummaryResponse, error) {
if id == "" {
return nil, fmt.Errorf("tender ID cannot be empty")
}
// Get tender to find the notice_publication_id
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 := t.NoticePublicationID
if noticeID == "" {
return nil, fmt.Errorf("tender has no notice publication ID")
}
// Try to get summary from MinIO storage (async pipeline)
if s.aiSummarizerStorage != nil {
summary, err := s.aiSummarizerStorage.GetSummaryFromStorage(ctx, noticeID)
if err == nil {
return &AISummaryResponse{
NoticeID: noticeID,
OverallSummary: summary,
Source: "storage",
}, nil
}
// Log but don't fail — the summary might just not be ready yet
s.logger.Info("AI summary not available from storage", map[string]interface{}{
"tender_id": id,
"notice_id": noticeID,
"error": err.Error(),
})
// Return specific error if summary not ready
if errors.Is(err, ai_summarizer.ErrSummaryNotReady) {
return nil, fmt.Errorf("AI summary is not ready yet, the pipeline is still processing")
}
if errors.Is(err, ai_summarizer.ErrObjectNotFound) {
return nil, fmt.Errorf("AI summary not found — this tender has not been processed by the AI pipeline yet")
}
return nil, fmt.Errorf("failed to get AI summary: %w", err)
}
return nil, fmt.Errorf("AI summarizer storage is not configured")
}
// 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")
}
noticeID := t.NoticePublicationID
if noticeID == "" {
return nil, fmt.Errorf("tender has no notice publication ID")
}
// Build the AI summarizer request
reqBody := ai_summarizer.SummarizeRequest{
NoticeID: noticeID,
DocumentURL: t.DocumentURI,
Title: t.Title,
Description: t.Description,
Country: t.CountryCode,
SubmissionDeadline: time.UnixMilli(t.SubmissionDeadline).Format(time.RFC3339),
}
if t.EstimatedValue > 0 {
ev := t.EstimatedValue
reqBody.EstimatedValue = &ev
}
if t.Currency != "" {
reqBody.Currency = t.Currency
}
if t.BuyerOrganization != nil {
reqBody.AuthorityName = t.BuyerOrganization.Name
}
s.logger.Info("Triggering on-demand AI summarization", map[string]interface{}{
"tender_id": id,
"notice_id": noticeID,
})
// 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,
"notice_id": noticeID,
"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,
"notice_id": noticeID,
"summaries_count": len(summaries),
})
return &AISummarizeResponse{
NoticeID: resp.NoticeID,
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) {
if s.aiSummarizerStorage == nil {
return nil, fmt.Errorf("AI summarizer storage is not configured")
}
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 {
s.logger.Error("Failed to get all AI summaries", map[string]interface{}{
"error": err.Error(),
})
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
}