Implement AI onboarding and recommendation features in company service

- Added new AI onboarding and recommendation endpoints in the company handler for starting onboarding and retrieving ranked tender recommendations.
- Introduced `StartAIOnboarding` and `GetAIRecommendations` methods in the company service to handle AI interactions.
- Updated the company service constructor to include the AI recommendation client.
- Enhanced the AI summarizer client with methods for onboarding and fetching recommendations.
- Added response structures for onboarding and recommended tenders in the company form.

This update enhances the tender management system by integrating AI capabilities for onboarding and tender recommendations, improving user experience and operational efficiency.
This commit is contained in:
Mazyar
2026-06-11 01:08:59 +03:30
parent 4f05516fc2
commit 3b26c4f5e1
8 changed files with 389 additions and 10 deletions
+3 -1
View File
@@ -167,9 +167,11 @@ func main() {
// Initialize AI Summarizer service // Initialize AI Summarizer service
var aiSummarizerClient tender.AISummarizerClient var aiSummarizerClient tender.AISummarizerClient
var aiSummarizerStorage tender.AISummarizerStorage var aiSummarizerStorage tender.AISummarizerStorage
var aiRecommendationClient company.AIRecommendationClient
if c := bootstrap.InitAISummarizerClient(conf.AISummarizer, mongoManager, logger); c != nil { if c := bootstrap.InitAISummarizerClient(conf.AISummarizer, mongoManager, logger); c != nil {
aiSummarizerClient = c aiSummarizerClient = c
aiRecommendationClient = c
} }
if s := bootstrap.InitAISummarizerStorage(conf.AISummarizer, logger); s != nil { if s := bootstrap.InitAISummarizerStorage(conf.AISummarizer, logger); s != nil {
aiSummarizerStorage = s aiSummarizerStorage = s
@@ -210,7 +212,7 @@ func main() {
auditLogger := audit.NewLogger(logger) auditLogger := audit.NewLogger(logger)
userService := user.NewService(userRepository, logger, userAuthService, userValidator, notificationSDK, redisClient, auditLogger) userService := user.NewService(userRepository, logger, userAuthService, userValidator, notificationSDK, redisClient, auditLogger)
categoryService := company_category.NewService(categoryRepository, logger) 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) customerService := customer.NewService(customerRepository, logger, customerAuthService, companyService, redisClient, notificationSDK, customerValidator, auditLogger)
tenderService := tender.NewService(tenderRepository, companyService, customerService, logger, aiSummarizerClient, aiSummarizerStorage, conf.AISummarizer.DefaultLanguage) tenderService := tender.NewService(tenderRepository, companyService, customerService, logger, aiSummarizerClient, aiSummarizerStorage, conf.AISummarizer.DefaultLanguage)
feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger) feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger)
+8
View File
@@ -291,6 +291,14 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tende
companiesGP.GET("", companyHandler.MyCompany) 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 // Public Inquiry Routes
inquiryGP := v1.Group("/inquiries") inquiryGP := v1.Group("/inquiries")
{ {
+12
View File
@@ -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 // CompanyProfileResponse represents the company profile data sent in API responses
type CompanyProfileResponse struct { type CompanyProfileResponse struct {
ID string `json:"id"` ID string `json:"id"`
+67
View File
@@ -1,6 +1,7 @@
package company package company
import ( import (
"errors"
"mime/multipart" "mime/multipart"
"tm/internal/user" "tm/internal/user"
@@ -53,6 +54,72 @@ func (h *Handler) MyCompany(c echo.Context) error {
return response.Success(c, company, "Company retrieved successfully") 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 **************************** // **************************** ADMIN ENDPOINTS ****************************
// Create creates a new company (Web Panel) // Create creates a new company (Web Panel)
+186
View File
@@ -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
}
+23 -9
View File
@@ -34,23 +34,37 @@ type Service interface {
// UploadDocuments uploads files and attaches them to a company // UploadDocuments uploads files and attaches them to a company
UploadDocuments(ctx context.Context, companyID, uploadedBy string, files []*multipart.FileHeader) (*UploadDocumentsResponse, error) 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 // companyService implements the Service interface
type companyService struct { type companyService struct {
repository Repository repository Repository
categoryService company_category.Service categoryService company_category.Service
fileStore filestore.FileStoreService fileStore filestore.FileStoreService
logger logger.Logger aiRecommendationClient AIRecommendationClient
logger logger.Logger
} }
// NewService creates a new company service // 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{ return &companyService{
repository: repository, repository: repository,
categoryService: categoryService, categoryService: categoryService,
fileStore: fileStore, fileStore: fileStore,
logger: logger, aiRecommendationClient: aiRecommendationClient,
logger: logger,
} }
} }
+66
View File
@@ -209,6 +209,72 @@ func (c *Client) TriggerPipelineSummarize(ctx context.Context) (*PipelineActionR
return c.triggerPipelineAction(ctx, "/pipeline/summarize", "pipeline summarize") 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) { func (c *Client) triggerPipelineAction(ctx context.Context, path, label string) (*PipelineActionResponse, error) {
url := c.config.APIBaseURL + path url := c.config.APIBaseURL + path
httpResp, bodyBytes, err := c.doRawPost(ctx, url, nil) httpResp, bodyBytes, err := c.doRawPost(ctx, url, nil)
+24
View File
@@ -138,6 +138,30 @@ type PipelineActionResponse struct {
Status string `json:"status"` 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. // StoredTranslation is one language entry inside tender.json translations map.
type StoredTranslation struct { type StoredTranslation struct {
Title string `json:"title"` Title string `json:"title"`