diff --git a/cmd/web/main.go b/cmd/web/main.go index f7ac0ac..8b6d241 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -167,9 +167,11 @@ func main() { // Initialize AI Summarizer service var aiSummarizerClient tender.AISummarizerClient var aiSummarizerStorage tender.AISummarizerStorage + var aiRecommendationClient company.AIRecommendationClient if c := bootstrap.InitAISummarizerClient(conf.AISummarizer, mongoManager, logger); c != nil { aiSummarizerClient = c + aiRecommendationClient = c } if s := bootstrap.InitAISummarizerStorage(conf.AISummarizer, logger); s != nil { aiSummarizerStorage = s @@ -210,7 +212,7 @@ func main() { auditLogger := audit.NewLogger(logger) userService := user.NewService(userRepository, logger, userAuthService, userValidator, notificationSDK, redisClient, auditLogger) categoryService := company_category.NewService(categoryRepository, logger) - companyService := company.NewService(companyRepository, categoryService, fileStoreService, logger) + companyService := company.NewService(companyRepository, categoryService, fileStoreService, aiRecommendationClient, logger) customerService := customer.NewService(customerRepository, logger, customerAuthService, companyService, redisClient, notificationSDK, customerValidator, auditLogger) tenderService := tender.NewService(tenderRepository, companyService, customerService, logger, aiSummarizerClient, aiSummarizerStorage, conf.AISummarizer.DefaultLanguage) feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger) diff --git a/cmd/web/router/routes.go b/cmd/web/router/routes.go index 78625b8..9d4aaf7 100644 --- a/cmd/web/router/routes.go +++ b/cmd/web/router/routes.go @@ -291,6 +291,14 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tende companiesGP.GET("", companyHandler.MyCompany) } + // AI tender recommendation routes + recommendationGP := v1.Group("") + recommendationGP.Use(customerHandler.AuthMiddleware()) + { + recommendationGP.POST("/onboarding", companyHandler.StartOnboarding) + recommendationGP.POST("/recommend", companyHandler.RecommendTenders) + } + // Public Inquiry Routes inquiryGP := v1.Group("/inquiries") { diff --git a/internal/company/form.go b/internal/company/form.go index 80a8942..2cd4c58 100644 --- a/internal/company/form.go +++ b/internal/company/form.go @@ -185,6 +185,18 @@ func (c *CompanyTags) ToResponse(categories []*CategoryResponse) *CompanyTagsRes } } +// OnboardingResponse is returned after starting AI company onboarding. +type OnboardingResponse struct { + Status string `json:"status"` +} + +// RecommendedTenderResponse is one ranked tender recommendation from the AI service. +type RecommendedTenderResponse struct { + Rank string `json:"rank"` + TenderID string `json:"tender_id"` + Analysis string `json:"analysis"` +} + // CompanyProfileResponse represents the company profile data sent in API responses type CompanyProfileResponse struct { ID string `json:"id"` diff --git a/internal/company/handler.go b/internal/company/handler.go index c7ad89e..a16d2c5 100644 --- a/internal/company/handler.go +++ b/internal/company/handler.go @@ -1,6 +1,7 @@ package company import ( + "errors" "mime/multipart" "tm/internal/user" @@ -53,6 +54,72 @@ func (h *Handler) MyCompany(c echo.Context) error { return response.Success(c, company, "Company retrieved successfully") } +// StartOnboarding starts AI onboarding for the authenticated customer's company (Mobile App) +// @Summary Start AI company onboarding +// @Description Send company profile, uploaded documents, and website URL to the AI team to start tender recommendation onboarding +// @Tags Company +// @Accept json +// @Produce json +// @Success 200 {object} response.APIResponse{data=OnboardingResponse} +// @Failure 401 {object} response.APIResponse "Unauthorized - User not authenticated" +// @Failure 404 {object} response.APIResponse "Company not found" +// @Failure 503 {object} response.APIResponse "AI recommendation service unavailable" +// @Failure 500 {object} response.APIResponse "Internal server error" +// @Security BearerAuth +// @Router /api/v1/onboarding [post] +func (h *Handler) StartOnboarding(c echo.Context) error { + companyID, err := user.GetCompanyIDFromContext(c) + if err != nil { + return response.Unauthorized(c, "User not authenticated") + } + + result, err := h.service.StartAIOnboarding(c.Request().Context(), companyID) + if err != nil { + if errors.Is(err, ErrAIRecommendationNotConfigured) { + return response.ServiceUnavailable(c, "AI recommendation service is not configured") + } + if err.Error() == "company not found" { + return response.NotFound(c, "Company not found") + } + return response.InternalServerError(c, "Failed to start AI onboarding") + } + + return response.Success(c, result, "AI onboarding started successfully") +} + +// RecommendTenders returns AI-ranked tender recommendations for the authenticated customer's company (Mobile App) +// @Summary Get AI tender recommendations +// @Description Retrieve ranked tender recommendations from the AI team for the authenticated customer's company +// @Tags Company +// @Accept json +// @Produce json +// @Success 200 {object} response.APIResponse{data=[]RecommendedTenderResponse} +// @Failure 401 {object} response.APIResponse "Unauthorized - User not authenticated" +// @Failure 404 {object} response.APIResponse "Company not found" +// @Failure 503 {object} response.APIResponse "AI recommendation service unavailable" +// @Failure 500 {object} response.APIResponse "Internal server error" +// @Security BearerAuth +// @Router /api/v1/recommend [post] +func (h *Handler) RecommendTenders(c echo.Context) error { + companyID, err := user.GetCompanyIDFromContext(c) + if err != nil { + return response.Unauthorized(c, "User not authenticated") + } + + result, err := h.service.GetAIRecommendations(c.Request().Context(), companyID) + if err != nil { + if errors.Is(err, ErrAIRecommendationNotConfigured) { + return response.ServiceUnavailable(c, "AI recommendation service is not configured") + } + if err.Error() == "company not found" { + return response.NotFound(c, "Company not found") + } + return response.InternalServerError(c, "Failed to retrieve AI recommendations") + } + + return response.Success(c, result, "AI recommendations retrieved successfully") +} + // **************************** ADMIN ENDPOINTS **************************** // Create creates a new company (Web Panel) diff --git a/internal/company/recommendation.go b/internal/company/recommendation.go new file mode 100644 index 0000000..7d9654b --- /dev/null +++ b/internal/company/recommendation.go @@ -0,0 +1,186 @@ +package company + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "tm/pkg/ai_summarizer" +) + +var ErrAIRecommendationNotConfigured = errors.New("AI recommendation service is not configured") + +// AIRecommendationClient defines the interface for company tender recommendation AI operations. +type AIRecommendationClient interface { + StartOnboarding(ctx context.Context, reqBody ai_summarizer.OnboardingRequest) (*ai_summarizer.OnboardingResponse, error) + FetchRecommendations(ctx context.Context, reqBody ai_summarizer.RecommendRequest) ([]ai_summarizer.RecommendedTender, error) +} + +type onboardingDocumentPayload struct { + Filename string `json:"filename"` + ContentType string `json:"content_type"` + Content string `json:"content"` +} + +// StartAIOnboarding sends company profile data to the AI team to start recommendation onboarding. +func (s *companyService) StartAIOnboarding(ctx context.Context, companyID string) (*OnboardingResponse, error) { + if s.aiRecommendationClient == nil { + return nil, ErrAIRecommendationNotConfigured + } + if strings.TrimSpace(companyID) == "" { + return nil, errors.New("company ID is required") + } + + company, err := s.repository.GetByID(ctx, companyID) + if err != nil { + s.logger.Error("Failed to load company for AI onboarding", map[string]interface{}{ + "company_id": companyID, + "error": err.Error(), + }) + return nil, errors.New("company not found") + } + + documents, err := s.buildOnboardingDocuments(company.DocumentFileIDs) + if err != nil { + s.logger.Error("Failed to prepare company documents for AI onboarding", map[string]interface{}{ + "company_id": companyID, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to prepare company documents: %w", err) + } + + websiteURL := "" + if company.Website != nil { + websiteURL = strings.TrimSpace(*company.Website) + } + + s.logger.Info("Starting AI company onboarding", map[string]interface{}{ + "company_id": companyID, + "documents_count": len(company.DocumentFileIDs), + "has_website_url": websiteURL != "", + }) + + result, err := s.aiRecommendationClient.StartOnboarding(ctx, ai_summarizer.OnboardingRequest{ + CompanyID: companyID, + Documents: documents, + WebsiteURL: websiteURL, + }) + if err != nil { + s.logger.Error("AI onboarding request failed", map[string]interface{}{ + "company_id": companyID, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to start AI onboarding: %w", err) + } + + return &OnboardingResponse{Status: result.Status}, nil +} + +// GetAIRecommendations retrieves ranked tender recommendations from the AI team. +func (s *companyService) GetAIRecommendations(ctx context.Context, companyID string) ([]RecommendedTenderResponse, error) { + if s.aiRecommendationClient == nil { + return nil, ErrAIRecommendationNotConfigured + } + if strings.TrimSpace(companyID) == "" { + return nil, errors.New("company ID is required") + } + + if _, err := s.repository.GetByID(ctx, companyID); err != nil { + s.logger.Error("Failed to load company for AI recommendations", map[string]interface{}{ + "company_id": companyID, + "error": err.Error(), + }) + return nil, errors.New("company not found") + } + + s.logger.Info("Fetching AI tender recommendations", map[string]interface{}{ + "company_id": companyID, + }) + + items, err := s.aiRecommendationClient.FetchRecommendations(ctx, ai_summarizer.RecommendRequest{ + CompanyID: companyID, + }) + if err != nil { + s.logger.Error("AI recommend request failed", map[string]interface{}{ + "company_id": companyID, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to fetch AI recommendations: %w", err) + } + + responses := make([]RecommendedTenderResponse, 0, len(items)) + for _, item := range items { + responses = append(responses, RecommendedTenderResponse{ + Rank: item.Rank, + TenderID: item.TenderID, + Analysis: item.Analysis, + }) + } + + return responses, nil +} + +func (s *companyService) buildOnboardingDocuments(fileIDs []string) (string, error) { + if len(fileIDs) == 0 { + return "[]", nil + } + if s.fileStore == nil { + return "", errors.New("file storage is not configured") + } + + payloads := make([]onboardingDocumentPayload, 0, len(fileIDs)) + for _, fileID := range fileIDs { + if strings.TrimSpace(fileID) == "" { + continue + } + + reader, info, err := s.fileStore.Download(fileID) + if err != nil { + s.logger.Warn("Skipping company document for AI onboarding", map[string]interface{}{ + "file_id": fileID, + "error": err.Error(), + }) + continue + } + + content, readErr := io.ReadAll(reader) + closeErr := reader.Close() + if readErr != nil { + return "", fmt.Errorf("failed to read document %s: %w", fileID, readErr) + } + if closeErr != nil { + s.logger.Warn("Failed to close company document reader", map[string]interface{}{ + "file_id": fileID, + "error": closeErr.Error(), + }) + } + + filename := fileID + contentType := "application/octet-stream" + if info != nil { + if info.Filename != "" { + filename = info.Filename + } + if info.ContentType != "" { + contentType = info.ContentType + } + } + + payloads = append(payloads, onboardingDocumentPayload{ + Filename: filename, + ContentType: contentType, + Content: base64.StdEncoding.EncodeToString(content), + }) + } + + encoded, err := json.Marshal(payloads) + if err != nil { + return "", fmt.Errorf("failed to encode company documents: %w", err) + } + + return string(encoded), nil +} diff --git a/internal/company/service.go b/internal/company/service.go index 9590b88..e0db91a 100644 --- a/internal/company/service.go +++ b/internal/company/service.go @@ -34,23 +34,37 @@ type Service interface { // UploadDocuments uploads files and attaches them to a company UploadDocuments(ctx context.Context, companyID, uploadedBy string, files []*multipart.FileHeader) (*UploadDocumentsResponse, error) + + // StartAIOnboarding sends company data to the AI team for recommendation onboarding + StartAIOnboarding(ctx context.Context, companyID string) (*OnboardingResponse, error) + + // GetAIRecommendations returns ranked tender recommendations from the AI team + GetAIRecommendations(ctx context.Context, companyID string) ([]RecommendedTenderResponse, error) } // companyService implements the Service interface type companyService struct { - repository Repository - categoryService company_category.Service - fileStore filestore.FileStoreService - logger logger.Logger + repository Repository + categoryService company_category.Service + fileStore filestore.FileStoreService + aiRecommendationClient AIRecommendationClient + logger logger.Logger } // NewService creates a new company service -func NewService(repository Repository, categoryService company_category.Service, fileStore filestore.FileStoreService, logger logger.Logger) Service { +func NewService( + repository Repository, + categoryService company_category.Service, + fileStore filestore.FileStoreService, + aiRecommendationClient AIRecommendationClient, + logger logger.Logger, +) Service { return &companyService{ - repository: repository, - categoryService: categoryService, - fileStore: fileStore, - logger: logger, + repository: repository, + categoryService: categoryService, + fileStore: fileStore, + aiRecommendationClient: aiRecommendationClient, + logger: logger, } } diff --git a/internal/notification/form.go b/internal/notification/form.go index 7878db1..1a15eb5 100644 --- a/internal/notification/form.go +++ b/internal/notification/form.go @@ -1,6 +1,8 @@ package notification import ( + "strings" + "tm/pkg/response" ) @@ -21,13 +23,26 @@ type NotificationRequest struct { // SearchForm represents the form for searching notifications type SearchForm struct { - Status string `query:"status" valid:"optional"` - Method string `query:"method" valid:"optional"` - EventType string `query:"event_type" valid:"optional"` - Type string `query:"type" valid:"optional"` + Q *string `query:"q" valid:"optional"` + Search *string `query:"search" valid:"optional"` + Status string `query:"status" valid:"optional"` + Method string `query:"method" valid:"optional"` + EventType string `query:"event_type" valid:"optional"` + Type string `query:"type" valid:"optional"` Recipient []string `query:"recipient" json:"recipient" valid:"optional"` - Seen *bool `query:"seen" valid:"optional"` - Priority string `query:"priority" valid:"optional"` + Seen *bool `query:"seen" valid:"optional"` + Priority string `query:"priority" valid:"optional"` +} + +// ResolvedSearch returns the search term from either the search or q query parameter. +func (f *SearchForm) ResolvedSearch() string { + if f.Search != nil && strings.TrimSpace(*f.Search) != "" { + return strings.TrimSpace(*f.Search) + } + if f.Q != nil { + return strings.TrimSpace(*f.Q) + } + return "" } type NotificationListResponse struct { diff --git a/internal/notification/handler.go b/internal/notification/handler.go index 74922ae..73236f2 100644 --- a/internal/notification/handler.go +++ b/internal/notification/handler.go @@ -58,6 +58,8 @@ func (h *NotificationHandler) Send(c echo.Context) error { // @Tags Admin-Notification // @Accept json // @Produce json +// @Param search query string false "Search query for title and message" +// @Param q query string false "Search query for title and message (alias of search)" // @Param status query string false "Status filter" // @Param method query string false "Method filter" // @Param event_type query string false "Event type filter" diff --git a/internal/notification/repository.go b/internal/notification/repository.go index 98f9c9f..dcb8ea4 100644 --- a/internal/notification/repository.go +++ b/internal/notification/repository.go @@ -3,6 +3,8 @@ package notification import ( "context" "errors" + "regexp" + "strings" "time" "tm/pkg/logger" @@ -52,7 +54,7 @@ func (n *Notification) GetID() string { type Repository interface { Create(ctx context.Context, notification *Notification) error GetByID(ctx context.Context, id string) (*Notification, error) - GetByUserID(ctx context.Context, userIDs []string, status string, seen *bool, priority, eventType, notificationType string, pagination *response.Pagination) (*orm.PaginatedResult[Notification], error) + GetByUserID(ctx context.Context, userIDs []string, search, status string, seen *bool, priority, eventType, notificationType string, pagination *response.Pagination) (*orm.PaginatedResult[Notification], error) Update(ctx context.Context, notification *Notification) error MarkAsSeen(ctx context.Context, notificationID, userID string) error MarkAllAsSeen(ctx context.Context, userID string) error @@ -115,9 +117,17 @@ func (r *notificationRepository) GetByID(ctx context.Context, id string) (*Notif } // GetByUserID retrieves notifications with optional user filters -func (r *notificationRepository) GetByUserID(ctx context.Context, userIDs []string, status string, seen *bool, priority, eventType, notificationType string, pagination *response.Pagination) (*orm.PaginatedResult[Notification], error) { +func (r *notificationRepository) GetByUserID(ctx context.Context, userIDs []string, search, status string, seen *bool, priority, eventType, notificationType string, pagination *response.Pagination) (*orm.PaginatedResult[Notification], error) { filter := bson.M{} + if q := strings.TrimSpace(search); q != "" { + pattern := regexp.QuoteMeta(q) + filter["$or"] = bson.A{ + bson.M{"title": bson.M{"$regex": pattern, "$options": "i"}}, + bson.M{"message": bson.M{"$regex": pattern, "$options": "i"}}, + } + } + if len(userIDs) == 1 { filter["user_id"] = userIDs[0] } else if len(userIDs) > 1 { diff --git a/internal/notification/service.go b/internal/notification/service.go index 208ce81..59eb593 100644 --- a/internal/notification/service.go +++ b/internal/notification/service.go @@ -183,7 +183,10 @@ func (s *notificationService) persistNotification(ctx context.Context, recipient } func (s *notificationService) GetNotifications(ctx context.Context, req *SearchForm, pagination *response.Pagination) (*NotificationListResponse, error) { + search := req.ResolvedSearch() + s.logger.Info("Getting notifications", map[string]interface{}{ + "search": search, "status": req.Status, "method": req.Method, "event_type": req.EventType, @@ -195,7 +198,7 @@ func (s *notificationService) GetNotifications(ctx context.Context, req *SearchF "offset": pagination.Offset, }) - page, err := s.repository.GetByUserID(ctx, req.Recipient, req.Status, req.Seen, req.Priority, req.EventType, req.Type, pagination) + page, err := s.repository.GetByUserID(ctx, req.Recipient, search, req.Status, req.Seen, req.Priority, req.EventType, req.Type, pagination) if err != nil { s.logger.Error("Failed to get notifications", map[string]interface{}{ "error": err.Error(), diff --git a/pkg/ai_summarizer/client.go b/pkg/ai_summarizer/client.go index 70c3551..6d4f620 100644 --- a/pkg/ai_summarizer/client.go +++ b/pkg/ai_summarizer/client.go @@ -209,6 +209,72 @@ func (c *Client) TriggerPipelineSummarize(ctx context.Context) (*PipelineActionR return c.triggerPipelineAction(ctx, "/pipeline/summarize", "pipeline summarize") } +// StartOnboarding calls POST /onboarding to start company profile indexing for recommendations. +func (c *Client) StartOnboarding(ctx context.Context, reqBody OnboardingRequest) (*OnboardingResponse, error) { + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("ai_summarizer: failed to marshal onboarding request body: %w", err) + } + + url := c.config.APIBaseURL + "/onboarding" + httpResp, bodyBytes, err := c.doRawPost(ctx, url, jsonBody) + if err != nil { + return nil, err + } + defer httpResp.Body.Close() + + if httpResp.StatusCode >= 400 { + return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes)) + } + + var result OnboardingResponse + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, &result); err != nil { + return nil, fmt.Errorf("ai_summarizer: failed to decode onboarding response: %w", err) + } + } + + c.logger.Info("AI onboarding request succeeded", map[string]interface{}{ + "company_id": reqBody.CompanyID, + "status": result.Status, + }) + + return &result, nil +} + +// FetchRecommendations calls POST /recommend to retrieve ranked tenders for a company. +func (c *Client) FetchRecommendations(ctx context.Context, reqBody RecommendRequest) ([]RecommendedTender, error) { + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("ai_summarizer: failed to marshal recommend request body: %w", err) + } + + url := c.config.APIBaseURL + "/recommend" + httpResp, bodyBytes, err := c.doRawPost(ctx, url, jsonBody) + if err != nil { + return nil, err + } + defer httpResp.Body.Close() + + if httpResp.StatusCode >= 400 { + return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes)) + } + + var result []RecommendedTender + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, &result); err != nil { + return nil, fmt.Errorf("ai_summarizer: failed to decode recommend response: %w", err) + } + } + + c.logger.Info("AI recommend request succeeded", map[string]interface{}{ + "company_id": reqBody.CompanyID, + "count": len(result), + }) + + return result, nil +} + func (c *Client) triggerPipelineAction(ctx context.Context, path, label string) (*PipelineActionResponse, error) { url := c.config.APIBaseURL + path httpResp, bodyBytes, err := c.doRawPost(ctx, url, nil) diff --git a/pkg/ai_summarizer/entities.go b/pkg/ai_summarizer/entities.go index 430d04b..d751f49 100644 --- a/pkg/ai_summarizer/entities.go +++ b/pkg/ai_summarizer/entities.go @@ -138,6 +138,30 @@ type PipelineActionResponse struct { Status string `json:"status"` } +// OnboardingRequest is the payload for POST /onboarding. +type OnboardingRequest struct { + CompanyID string `json:"company_id"` + Documents string `json:"documents"` + WebsiteURL string `json:"website_url"` +} + +// OnboardingResponse is returned by POST /onboarding. +type OnboardingResponse struct { + Status string `json:"status"` +} + +// RecommendRequest is the payload for POST /recommend. +type RecommendRequest struct { + CompanyID string `json:"company_id"` +} + +// RecommendedTender is one ranked tender from POST /recommend. +type RecommendedTender struct { + Rank string `json:"rank"` + TenderID string `json:"tender_id"` + Analysis string `json:"analysis"` +} + // StoredTranslation is one language entry inside tender.json translations map. type StoredTranslation struct { Title string `json:"title"`