Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42d64c4ec6 | |||
| dcf19b91cd | |||
| a825c4a271 | |||
| 416b040c2b | |||
| 24c57a3ec5 | |||
| f16f9fb5a9 | |||
| 3b26c4f5e1 |
+14
-4
@@ -59,6 +59,9 @@ package main
|
|||||||
// @tag.name Admin-CMS
|
// @tag.name Admin-CMS
|
||||||
// @tag.description Administrative CMS management operations for web panel including CRUD operations, search, statistics, and comprehensive filtering with pagination
|
// @tag.description Administrative CMS management operations for web panel including CRUD operations, search, statistics, and comprehensive filtering with pagination
|
||||||
|
|
||||||
|
// @tag.name Admin-AI-Pipeline
|
||||||
|
// @tag.description Administrative Opplens AI pipeline operations including scrape, summarize, translate, sync, and background pipeline runs
|
||||||
|
|
||||||
// @tag.name Authorization
|
// @tag.name Authorization
|
||||||
// @tag.description Customer authentication and authorization operations for mobile application including login, logout, token refresh, and profile access
|
// @tag.description Customer authentication and authorization operations for mobile application including login, logout, token refresh, and profile access
|
||||||
|
|
||||||
@@ -98,6 +101,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
"tm/internal/assets"
|
"tm/internal/assets"
|
||||||
|
"tm/internal/ai_pipeline"
|
||||||
"tm/internal/cms"
|
"tm/internal/cms"
|
||||||
"tm/internal/company"
|
"tm/internal/company"
|
||||||
"tm/internal/company_category"
|
"tm/internal/company_category"
|
||||||
@@ -167,9 +171,13 @@ 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
|
||||||
|
var aiPipelineClient ai_pipeline.Client
|
||||||
|
|
||||||
if c := bootstrap.InitAISummarizerClient(conf.AISummarizer, mongoManager, logger); c != nil {
|
if c := bootstrap.InitAISummarizerClient(conf.AISummarizer, mongoManager, logger); c != nil {
|
||||||
aiSummarizerClient = c
|
aiSummarizerClient = c
|
||||||
|
aiPipelineClient = 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 +218,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)
|
||||||
@@ -224,8 +232,9 @@ func main() {
|
|||||||
documentScraperService := document_scraper.NewService(tenderRepository, logger)
|
documentScraperService := document_scraper.NewService(tenderRepository, logger)
|
||||||
dashboardRepository := dashboard.NewRepository(mongoManager, logger)
|
dashboardRepository := dashboard.NewRepository(mongoManager, logger)
|
||||||
dashboardService := dashboard.NewService(dashboardRepository, logger)
|
dashboardService := dashboard.NewService(dashboardRepository, logger)
|
||||||
|
aiPipelineService := ai_pipeline.NewService(aiPipelineClient, logger)
|
||||||
logger.Info("Services initialized successfully", map[string]interface{}{
|
logger.Info("Services initialized successfully", map[string]interface{}{
|
||||||
"services": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban", "document_scraper", "dashboard"},
|
"services": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban", "document_scraper", "dashboard", "ai_pipeline"},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize handlers with services
|
// Initialize handlers with services
|
||||||
@@ -245,8 +254,9 @@ func main() {
|
|||||||
fileStoreHandler := filestore.NewHandler(fileStoreService, logger)
|
fileStoreHandler := filestore.NewHandler(fileStoreService, logger)
|
||||||
documentScraperHandler := document_scraper.NewHandler(documentScraperService, logger)
|
documentScraperHandler := document_scraper.NewHandler(documentScraperService, logger)
|
||||||
dashboardHandler := dashboard.NewHandler(dashboardService)
|
dashboardHandler := dashboard.NewHandler(dashboardService)
|
||||||
|
aiPipelineHandler := ai_pipeline.NewHandler(aiPipelineService)
|
||||||
logger.Info("Handlers initialized successfully", map[string]interface{}{
|
logger.Info("Handlers initialized successfully", map[string]interface{}{
|
||||||
"handlers": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban", "filestore", "document_scraper", "dashboard"},
|
"handlers": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban", "filestore", "document_scraper", "dashboard", "ai_pipeline"},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize HTTP server
|
// Initialize HTTP server
|
||||||
@@ -255,7 +265,7 @@ func main() {
|
|||||||
router.SetupCSPSecurity(e)
|
router.SetupCSPSecurity(e)
|
||||||
|
|
||||||
// Register routes
|
// Register routes
|
||||||
router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, dashboardHandler, xssPolicy)
|
router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, dashboardHandler, aiPipelineHandler, xssPolicy)
|
||||||
router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, xssPolicy)
|
router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, xssPolicy)
|
||||||
router.RegisterDocumentScraperRoutes(e, documentScraperHandler, conf.DocumentScraper.APIKey)
|
router.RegisterDocumentScraperRoutes(e, documentScraperHandler, conf.DocumentScraper.APIKey)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"tm/internal/assets"
|
"tm/internal/assets"
|
||||||
|
"tm/internal/ai_pipeline"
|
||||||
"tm/internal/cms"
|
"tm/internal/cms"
|
||||||
"tm/internal/company"
|
"tm/internal/company"
|
||||||
"tm/internal/company_category"
|
"tm/internal/company_category"
|
||||||
@@ -34,7 +35,7 @@ func SetupCSPSecurity(e *echo.Echo) {
|
|||||||
e.POST("/api/v1/csp-report", security.CSPReportHandler)
|
e.POST("/api/v1/csp-report", security.CSPReportHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, kanbanHandler *kanban.Handler, fileStoreHandler *filestore.Handler, dashboardHandler *dashboard.Handler, xssPolicy *security.XSSPolicy) {
|
func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, kanbanHandler *kanban.Handler, fileStoreHandler *filestore.Handler, dashboardHandler *dashboard.Handler, aiPipelineHandler *ai_pipeline.Handler, xssPolicy *security.XSSPolicy) {
|
||||||
adminV1 := e.Group("/admin/v1")
|
adminV1 := e.Group("/admin/v1")
|
||||||
|
|
||||||
// Admin Users Routes
|
// Admin Users Routes
|
||||||
@@ -225,6 +226,27 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler
|
|||||||
dashboardGP.GET("/recent", dashboardHandler.Recent)
|
dashboardGP.GET("/recent", dashboardHandler.Recent)
|
||||||
dashboardGP.GET("/statistics", dashboardHandler.Statistics)
|
dashboardGP.GET("/statistics", dashboardHandler.Statistics)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI Pipeline Routes (proxy to Opplens AI service)
|
||||||
|
aiPipelineGP := adminV1.Group("/ai-pipeline")
|
||||||
|
{
|
||||||
|
aiPipelineGP.Use(userHandler.AuthMiddleware())
|
||||||
|
aiPipelineGP.POST("/scrape/documents", aiPipelineHandler.ScrapeDocuments)
|
||||||
|
aiPipelineGP.POST("/scrape/documents/batch", aiPipelineHandler.ScrapeDocumentsBatch)
|
||||||
|
aiPipelineGP.POST("/summarize/batch", aiPipelineHandler.SummarizeBatch)
|
||||||
|
aiPipelineGP.POST("/translate/batch", aiPipelineHandler.TranslateBatch)
|
||||||
|
aiPipelineGP.POST("/sync", aiPipelineHandler.Sync)
|
||||||
|
aiPipelineGP.POST("/run", aiPipelineHandler.Run)
|
||||||
|
aiPipelineGP.POST("/run/batch", aiPipelineHandler.RunBatch)
|
||||||
|
aiPipelineGP.POST("/analyze", aiPipelineHandler.Analyze)
|
||||||
|
aiPipelineGP.POST("/daily-run", aiPipelineHandler.DailyRun)
|
||||||
|
aiPipelineGP.POST("/auto", aiPipelineHandler.Auto)
|
||||||
|
aiPipelineGP.GET("/last-run", aiPipelineHandler.GetLastRun)
|
||||||
|
aiPipelineGP.GET("/last-auto-run", aiPipelineHandler.GetLastAutoRun)
|
||||||
|
aiPipelineGP.GET("/report", aiPipelineHandler.GetReport)
|
||||||
|
aiPipelineGP.GET("/stats", aiPipelineHandler.GetStats)
|
||||||
|
aiPipelineGP.GET("/minio-stats", aiPipelineHandler.GetMinioStats)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, companyHandler *company.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, kanbanHandler *kanban.Handler, fileStoreHandler *filestore.Handler, xssPolicy *security.XSSPolicy) {
|
func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, companyHandler *company.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, kanbanHandler *kanban.Handler, fileStoreHandler *filestore.Handler, xssPolicy *security.XSSPolicy) {
|
||||||
@@ -291,6 +313,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")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,10 +47,13 @@ func (w *DocumentSummarizationWorker) Run() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := w.AIClient.TriggerPipelineSummarize(context.Background()); err != nil {
|
if refs := w.collectUnSummarizedTenderRefs(context.Background()); len(refs) > 0 {
|
||||||
w.Logger.Warn("Failed to trigger AI pipeline summarize (continuing with per-tender sync)", map[string]interface{}{
|
if _, err := w.AIClient.TriggerSummarizeBatch(context.Background(), refs); err != nil {
|
||||||
"error": err.Error(),
|
w.Logger.Warn("Failed to trigger AI summarize batch (continuing with per-tender sync)", map[string]interface{}{
|
||||||
})
|
"tender_count": len(refs),
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
limit := 5
|
limit := 5
|
||||||
@@ -92,6 +95,38 @@ func (w *DocumentSummarizationWorker) Run() {
|
|||||||
w.Logger.Info("Document summarization worker completed", map[string]interface{}{})
|
w.Logger.Info("Document summarization worker completed", map[string]interface{}{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *DocumentSummarizationWorker) collectUnSummarizedTenderRefs(ctx context.Context) []ai_summarizer.TenderRef {
|
||||||
|
limit := 100
|
||||||
|
skip := 0
|
||||||
|
refs := make([]ai_summarizer.TenderRef, 0)
|
||||||
|
|
||||||
|
for {
|
||||||
|
tenders, totalCount, err := w.TenderRepo.GetUnSummarizedTenders(ctx, limit, skip)
|
||||||
|
if err != nil {
|
||||||
|
w.Logger.Warn("Failed to collect tenders for summarize batch", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
if len(tenders) == 0 {
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range tenders {
|
||||||
|
t := &tenders[i]
|
||||||
|
if strings.TrimSpace(t.ContractFolderID) == "" || strings.TrimSpace(t.NoticePublicationID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
refs = append(refs, ai_summarizer.NewTenderRef(t.ContractFolderID, t.NoticePublicationID))
|
||||||
|
}
|
||||||
|
|
||||||
|
skip += len(tenders)
|
||||||
|
if int64(skip) >= totalCount {
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (w *DocumentSummarizationWorker) summarizeTender(t *tender.Tender) {
|
func (w *DocumentSummarizationWorker) summarizeTender(t *tender.Tender) {
|
||||||
if strings.TrimSpace(t.NoticePublicationID) == "" {
|
if strings.TrimSpace(t.NoticePublicationID) == "" {
|
||||||
w.Logger.Warn("Skipping tender summarization: missing notice_publication_id", map[string]interface{}{
|
w.Logger.Warn("Skipping tender summarization: missing notice_publication_id", map[string]interface{}{
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// TranslationWorker syncs translation completion from the AI pipeline MinIO storage.
|
// TranslationWorker syncs translation completion from the AI pipeline MinIO storage.
|
||||||
// Batch translation is triggered via POST /pipeline/translate; this worker only
|
// Batch translation is triggered via POST /ai/translate/batch; this worker only
|
||||||
// verifies which tenders now have translations available in storage.
|
// verifies which tenders now have translations available in storage.
|
||||||
type TranslationWorker struct {
|
type TranslationWorker struct {
|
||||||
Mongo *mongo.ConnectionManager
|
Mongo *mongo.ConnectionManager
|
||||||
@@ -82,11 +82,14 @@ func (w *TranslationWorker) Run() {
|
|||||||
|
|
||||||
startCount := w.getSuccessfulTranslationCount(mongo.AITranslationSuccessCounterKeyDailyJob)
|
startCount := w.getSuccessfulTranslationCount(mongo.AITranslationSuccessCounterKeyDailyJob)
|
||||||
|
|
||||||
if _, err := w.AIClient.TriggerPipelineTranslate(context.Background(), w.TargetLanguages); err != nil {
|
if refs := w.collectUnTranslatedTenderRefs(context.Background()); len(refs) > 0 {
|
||||||
w.Logger.Warn("Failed to trigger AI pipeline translate", map[string]interface{}{
|
if _, err := w.AIClient.TriggerTranslateBatch(context.Background(), refs, w.TargetLanguages); err != nil {
|
||||||
"target_languages": w.TargetLanguages,
|
w.Logger.Warn("Failed to trigger AI translate batch", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"tender_count": len(refs),
|
||||||
})
|
"target_languages": w.TargetLanguages,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
readyCount := 0
|
readyCount := 0
|
||||||
@@ -186,3 +189,44 @@ func (w *TranslationWorker) hasTranslationInStorage(t *tender.Tender, language s
|
|||||||
)
|
)
|
||||||
return err == nil && strings.TrimSpace(stored.Title) != ""
|
return err == nil && strings.TrimSpace(stored.Title) != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *TranslationWorker) collectUnTranslatedTenderRefs(ctx context.Context) []ai_summarizer.TenderRef {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
refs := make([]ai_summarizer.TenderRef, 0)
|
||||||
|
|
||||||
|
for _, language := range w.TargetLanguages {
|
||||||
|
skip := 0
|
||||||
|
for {
|
||||||
|
tenders, totalCount, err := w.TenderRepo.GetUnTranslatedTenders(ctx, language, w.BatchSize, skip)
|
||||||
|
if err != nil {
|
||||||
|
w.Logger.Warn("Failed to collect tenders for translate batch", map[string]interface{}{
|
||||||
|
"target_language": language,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if len(tenders) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range tenders {
|
||||||
|
if strings.TrimSpace(t.ContractFolderID) == "" || strings.TrimSpace(t.NoticePublicationID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := t.ContractFolderID + "|" + t.NoticePublicationID
|
||||||
|
if _, dup := seen[key]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
refs = append(refs, ai_summarizer.NewTenderRef(t.ContractFolderID, t.NoticePublicationID))
|
||||||
|
}
|
||||||
|
|
||||||
|
skip += len(tenders)
|
||||||
|
if int64(skip) >= totalCount {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package ai_pipeline
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrAINotConfigured is returned when the Opplens AI client is not configured.
|
||||||
|
ErrAINotConfigured = errors.New("ai_pipeline: AI service not configured")
|
||||||
|
|
||||||
|
// ErrEmptyTenderBatch is returned when a batch request has no tenders.
|
||||||
|
ErrEmptyTenderBatch = errors.New("ai_pipeline: at least one tender is required")
|
||||||
|
|
||||||
|
// ErrEmptyLanguages is returned when languages are required but missing.
|
||||||
|
ErrEmptyLanguages = errors.New("ai_pipeline: at least one language is required")
|
||||||
|
)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package ai_pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"tm/pkg/ai_summarizer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TenderRefForm identifies one tender in batch and pipeline requests.
|
||||||
|
type TenderRefForm struct {
|
||||||
|
ContractFolderID string `json:"contract_folder_id" valid:"required"`
|
||||||
|
NoticePublicationID string `json:"notice_publication_id" valid:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrapeDocumentsForm is the request for POST /admin/v1/ai-pipeline/scrape/documents.
|
||||||
|
type ScrapeDocumentsForm struct {
|
||||||
|
ContractFolderID string `json:"contract_folder_id" valid:"required"`
|
||||||
|
NoticePublicationID string `json:"notice_publication_id" valid:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenderBatchForm is the request body for batch scrape and summarize endpoints.
|
||||||
|
type TenderBatchForm struct {
|
||||||
|
Tenders []TenderRefForm `json:"tenders" valid:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranslateBatchForm is the request body for POST /admin/v1/ai-pipeline/translate/batch.
|
||||||
|
type TranslateBatchForm struct {
|
||||||
|
Tenders []TenderRefForm `json:"tenders" valid:"required"`
|
||||||
|
Languages []string `json:"languages" valid:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PipelineRunForm is the request body for POST /admin/v1/ai-pipeline/run.
|
||||||
|
type PipelineRunForm struct {
|
||||||
|
ContractFolderID string `json:"contract_folder_id" valid:"required"`
|
||||||
|
NoticePublicationID string `json:"notice_publication_id" valid:"required"`
|
||||||
|
Languages []string `json:"languages" valid:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PipelineRunBatchForm is the request body for POST /admin/v1/ai-pipeline/run/batch.
|
||||||
|
type PipelineRunBatchForm struct {
|
||||||
|
Tenders []TenderRefForm `json:"tenders" valid:"required"`
|
||||||
|
Languages []string `json:"languages" valid:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PipelineWindowQueryForm is the query form for report and stats endpoints.
|
||||||
|
type PipelineWindowQueryForm struct {
|
||||||
|
Window string `query:"window" valid:"optional,in(last_hour|last_24h|today|yesterday|last_7_days|last_30_days|all|daily)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toTenderRefs(forms []TenderRefForm) []ai_summarizer.TenderRef {
|
||||||
|
refs := make([]ai_summarizer.TenderRef, 0, len(forms))
|
||||||
|
for _, form := range forms {
|
||||||
|
refs = append(refs, ai_summarizer.NewTenderRef(form.ContractFolderID, form.NoticePublicationID))
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLanguages(languages []string) []string {
|
||||||
|
out := make([]string, 0, len(languages))
|
||||||
|
seen := make(map[string]struct{}, len(languages))
|
||||||
|
for _, language := range languages {
|
||||||
|
lang := strings.ToLower(strings.TrimSpace(language))
|
||||||
|
if lang == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, dup := seen[lang]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[lang] = struct{}{}
|
||||||
|
out = append(out, lang)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
package ai_pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"tm/pkg/ai_summarizer"
|
||||||
|
"tm/pkg/response"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler handles admin HTTP requests for Opplens AI pipeline operations.
|
||||||
|
type Handler struct {
|
||||||
|
service Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates a new AI pipeline handler.
|
||||||
|
func NewHandler(service Service) *Handler {
|
||||||
|
return &Handler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrapeDocuments scrapes documents for one tender via the Opplens AI service.
|
||||||
|
// @Summary Scrape tender documents
|
||||||
|
// @Description Download all documents for one tender through the Opplens AI service (synchronous, cached)
|
||||||
|
// @Tags Admin-AI-Pipeline
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body ScrapeDocumentsForm true "Tender identifiers"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=ai_summarizer.ScrapeDocumentsResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 404 {object} response.APIResponse
|
||||||
|
// @Failure 422 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Failure 503 {object} response.APIResponse
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /admin/v1/ai-pipeline/scrape/documents [post]
|
||||||
|
func (h *Handler) ScrapeDocuments(c echo.Context) error {
|
||||||
|
form, err := response.Parse[ScrapeDocumentsForm](c)
|
||||||
|
if err != nil {
|
||||||
|
return response.ValidationError(c, "Invalid request data", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.ScrapeDocuments(c.Request().Context(), *form)
|
||||||
|
if err != nil {
|
||||||
|
return mapPipelineHTTPError(c, err, "Scrape documents")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result, "Documents scraped successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrapeDocumentsBatch enqueues background document scraping for many tenders.
|
||||||
|
// @Summary Batch scrape tender documents
|
||||||
|
// @Description Enqueue background document scraping for multiple tenders via the Opplens AI service
|
||||||
|
// @Tags Admin-AI-Pipeline
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body TenderBatchForm true "Tenders to scrape"
|
||||||
|
// @Success 202 {object} response.APIResponse{data=ai_summarizer.BatchAcceptedResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 422 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Failure 503 {object} response.APIResponse
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /admin/v1/ai-pipeline/scrape/documents/batch [post]
|
||||||
|
func (h *Handler) ScrapeDocumentsBatch(c echo.Context) error {
|
||||||
|
form, err := response.Parse[TenderBatchForm](c)
|
||||||
|
if err != nil {
|
||||||
|
return response.ValidationError(c, "Invalid request data", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.ScrapeDocumentsBatch(c.Request().Context(), *form)
|
||||||
|
if err != nil {
|
||||||
|
return mapPipelineHTTPError(c, err, "Scrape documents batch")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Accepted(c, result, "Scrape batch accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SummarizeBatch enqueues background summarization for many tenders.
|
||||||
|
// @Summary Batch summarize tenders
|
||||||
|
// @Description Enqueue background AI summarization for multiple tenders via the Opplens AI service
|
||||||
|
// @Tags Admin-AI-Pipeline
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body TenderBatchForm true "Tenders to summarize"
|
||||||
|
// @Success 202 {object} response.APIResponse{data=ai_summarizer.BatchAcceptedResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 422 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Failure 503 {object} response.APIResponse
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /admin/v1/ai-pipeline/summarize/batch [post]
|
||||||
|
func (h *Handler) SummarizeBatch(c echo.Context) error {
|
||||||
|
form, err := response.Parse[TenderBatchForm](c)
|
||||||
|
if err != nil {
|
||||||
|
return response.ValidationError(c, "Invalid request data", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.SummarizeBatch(c.Request().Context(), *form)
|
||||||
|
if err != nil {
|
||||||
|
return mapPipelineHTTPError(c, err, "Summarize batch")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Accepted(c, result, "Summarize batch accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranslateBatch enqueues background translation for many tenders.
|
||||||
|
// @Summary Batch translate tenders
|
||||||
|
// @Description Enqueue background AI translation for multiple tenders via the Opplens AI service
|
||||||
|
// @Tags Admin-AI-Pipeline
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body TranslateBatchForm true "Tenders and target languages"
|
||||||
|
// @Success 202 {object} response.APIResponse{data=ai_summarizer.BatchAcceptedResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 422 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Failure 503 {object} response.APIResponse
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /admin/v1/ai-pipeline/translate/batch [post]
|
||||||
|
func (h *Handler) TranslateBatch(c echo.Context) error {
|
||||||
|
form, err := response.Parse[TranslateBatchForm](c)
|
||||||
|
if err != nil {
|
||||||
|
return response.ValidationError(c, "Invalid request data", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.TranslateBatch(c.Request().Context(), *form)
|
||||||
|
if err != nil {
|
||||||
|
return mapPipelineHTTPError(c, err, "Translate batch")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Accepted(c, result, "Translate batch accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync pulls the tender list from this backend into the Opplens AI service.
|
||||||
|
// @Summary Sync tenders to AI pipeline
|
||||||
|
// @Description Pull the tender list from the backend and store it in the Opplens AI service
|
||||||
|
// @Tags Admin-AI-Pipeline
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} response.APIResponse{data=ai_summarizer.PipelineSyncResponse}
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Failure 503 {object} response.APIResponse
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /admin/v1/ai-pipeline/sync [post]
|
||||||
|
func (h *Handler) Sync(c echo.Context) error {
|
||||||
|
result, err := h.service.Sync(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
return mapPipelineHTTPError(c, err, "Pipeline sync")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result, "Pipeline sync completed successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run runs the full AI pipeline for one tender (scrape → translate → summarize).
|
||||||
|
// @Summary Run full AI pipeline for one tender
|
||||||
|
// @Description Run scrape, translate, and summarize for one tender via the Opplens AI service
|
||||||
|
// @Tags Admin-AI-Pipeline
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body PipelineRunForm true "Tender and languages"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=object}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 404 {object} response.APIResponse
|
||||||
|
// @Failure 422 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Failure 503 {object} response.APIResponse
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /admin/v1/ai-pipeline/run [post]
|
||||||
|
func (h *Handler) Run(c echo.Context) error {
|
||||||
|
form, err := response.Parse[PipelineRunForm](c)
|
||||||
|
if err != nil {
|
||||||
|
return response.ValidationError(c, "Invalid request data", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.Run(c.Request().Context(), *form)
|
||||||
|
if err != nil {
|
||||||
|
return mapPipelineHTTPError(c, err, "Pipeline run")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result, "Pipeline run completed successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunBatch enqueues the full AI pipeline for many tenders.
|
||||||
|
// @Summary Batch run full AI pipeline
|
||||||
|
// @Description Enqueue scrape, translate, and summarize for multiple tenders via the Opplens AI service
|
||||||
|
// @Tags Admin-AI-Pipeline
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body PipelineRunBatchForm true "Tenders and languages"
|
||||||
|
// @Success 202 {object} response.APIResponse{data=ai_summarizer.BatchAcceptedResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 422 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Failure 503 {object} response.APIResponse
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /admin/v1/ai-pipeline/run/batch [post]
|
||||||
|
func (h *Handler) RunBatch(c echo.Context) error {
|
||||||
|
form, err := response.Parse[PipelineRunBatchForm](c)
|
||||||
|
if err != nil {
|
||||||
|
return response.ValidationError(c, "Invalid request data", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.RunBatch(c.Request().Context(), *form)
|
||||||
|
if err != nil {
|
||||||
|
return mapPipelineHTTPError(c, err, "Pipeline run batch")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Accepted(c, result, "Pipeline run batch accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze triggers analysis across stored tenders in the Opplens AI service.
|
||||||
|
// @Summary Trigger AI analysis pipeline
|
||||||
|
// @Description Trigger agentic analysis across stored tenders via the Opplens AI service
|
||||||
|
// @Tags Admin-AI-Pipeline
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} response.APIResponse{data=ai_summarizer.PipelineActionResponse}
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Failure 503 {object} response.APIResponse
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /admin/v1/ai-pipeline/analyze [post]
|
||||||
|
func (h *Handler) Analyze(c echo.Context) error {
|
||||||
|
result, err := h.service.Analyze(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
return mapPipelineHTTPError(c, err, "Pipeline analyze")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result, "Pipeline analyze triggered successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DailyRun syncs tenders and runs the full pipeline on newly synced tenders.
|
||||||
|
// @Summary Start daily AI pipeline run
|
||||||
|
// @Description Sync tenders, then run the full pipeline on newly synced tenders (background, single-flight)
|
||||||
|
// @Tags Admin-AI-Pipeline
|
||||||
|
// @Produce json
|
||||||
|
// @Success 202 {object} response.APIResponse{data=ai_summarizer.PipelineStartedResponse}
|
||||||
|
// @Failure 409 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Failure 503 {object} response.APIResponse
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /admin/v1/ai-pipeline/daily-run [post]
|
||||||
|
func (h *Handler) DailyRun(c echo.Context) error {
|
||||||
|
result, err := h.service.DailyRun(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
return mapPipelineHTTPError(c, err, "Pipeline daily-run")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Accepted(c, result, "Pipeline daily-run started")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto syncs tenders and completes all missing AI work across stored tenders.
|
||||||
|
// @Summary Start auto AI pipeline run
|
||||||
|
// @Description Sync tenders, then complete all missing scrape/translate/summarize work (background, single-flight, idempotent)
|
||||||
|
// @Tags Admin-AI-Pipeline
|
||||||
|
// @Produce json
|
||||||
|
// @Success 202 {object} response.APIResponse{data=ai_summarizer.PipelineStartedResponse}
|
||||||
|
// @Failure 409 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Failure 503 {object} response.APIResponse
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /admin/v1/ai-pipeline/auto [post]
|
||||||
|
func (h *Handler) Auto(c echo.Context) error {
|
||||||
|
result, err := h.service.Auto(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
return mapPipelineHTTPError(c, err, "Pipeline auto")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Accepted(c, result, "Pipeline auto started")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLastRun returns the result of the last daily-run.
|
||||||
|
// @Summary Get last daily-run report
|
||||||
|
// @Description Retrieve the report for the last pipeline daily-run from the Opplens AI service
|
||||||
|
// @Tags Admin-AI-Pipeline
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} response.APIResponse{data=ai_summarizer.PipelineReportResponse}
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Failure 503 {object} response.APIResponse
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /admin/v1/ai-pipeline/last-run [get]
|
||||||
|
func (h *Handler) GetLastRun(c echo.Context) error {
|
||||||
|
result, err := h.service.GetLastRun(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
return mapPipelineHTTPError(c, err, "Get last pipeline run")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result, "Last pipeline run retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLastAutoRun returns the result of the last auto run.
|
||||||
|
// @Summary Get last auto-run report
|
||||||
|
// @Description Retrieve the report for the last pipeline auto run from the Opplens AI service
|
||||||
|
// @Tags Admin-AI-Pipeline
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} response.APIResponse{data=ai_summarizer.PipelineReportResponse}
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Failure 503 {object} response.APIResponse
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /admin/v1/ai-pipeline/last-auto-run [get]
|
||||||
|
func (h *Handler) GetLastAutoRun(c echo.Context) error {
|
||||||
|
result, err := h.service.GetLastAutoRun(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
return mapPipelineHTTPError(c, err, "Get last pipeline auto run")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result, "Last pipeline auto run retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReport returns an event-log summary for a time window.
|
||||||
|
// @Summary Get AI pipeline event report
|
||||||
|
// @Description Retrieve an event-log summary for a time window from the Opplens AI service
|
||||||
|
// @Tags Admin-AI-Pipeline
|
||||||
|
// @Produce json
|
||||||
|
// @Param window query string false "Time window" Enums(last_hour,last_24h,today,yesterday,last_7_days,last_30_days,all,daily) default(last_24h)
|
||||||
|
// @Success 200 {object} response.APIResponse{data=ai_summarizer.PipelineReportResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 422 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Failure 503 {object} response.APIResponse
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /admin/v1/ai-pipeline/report [get]
|
||||||
|
func (h *Handler) GetReport(c echo.Context) error {
|
||||||
|
form, err := response.Parse[PipelineWindowQueryForm](c)
|
||||||
|
if err != nil {
|
||||||
|
return response.ValidationError(c, "Invalid query parameters", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.GetReport(c.Request().Context(), form.Window)
|
||||||
|
if err != nil {
|
||||||
|
return mapPipelineHTTPError(c, err, "Get pipeline report")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result, "Pipeline report retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats returns event-log and storage inventory for a time window.
|
||||||
|
// @Summary Get AI pipeline stats
|
||||||
|
// @Description Retrieve event-log summary and MinIO inventory for a time window from the Opplens AI service
|
||||||
|
// @Tags Admin-AI-Pipeline
|
||||||
|
// @Produce json
|
||||||
|
// @Param window query string false "Time window" Enums(last_hour,last_24h,today,yesterday,last_7_days,last_30_days,all,daily) default(last_24h)
|
||||||
|
// @Success 200 {object} response.APIResponse{data=ai_summarizer.PipelineStatsResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 422 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Failure 503 {object} response.APIResponse
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /admin/v1/ai-pipeline/stats [get]
|
||||||
|
func (h *Handler) GetStats(c echo.Context) error {
|
||||||
|
form, err := response.Parse[PipelineWindowQueryForm](c)
|
||||||
|
if err != nil {
|
||||||
|
return response.ValidationError(c, "Invalid query parameters", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.GetStats(c.Request().Context(), form.Window)
|
||||||
|
if err != nil {
|
||||||
|
return mapPipelineHTTPError(c, err, "Get pipeline stats")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result, "Pipeline stats retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMinioStats returns storage inventory from the Opplens AI service.
|
||||||
|
// @Summary Get AI pipeline MinIO stats
|
||||||
|
// @Description Retrieve storage inventory only from the Opplens AI service
|
||||||
|
// @Tags Admin-AI-Pipeline
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} response.APIResponse{data=ai_summarizer.PipelineMinioStatsResponse}
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Failure 503 {object} response.APIResponse
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /admin/v1/ai-pipeline/minio-stats [get]
|
||||||
|
func (h *Handler) GetMinioStats(c echo.Context) error {
|
||||||
|
result, err := h.service.GetMinioStats(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
return mapPipelineHTTPError(c, err, "Get pipeline minio stats")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result, "Pipeline MinIO stats retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapPipelineHTTPError(c echo.Context, err error, action string) error {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrAINotConfigured):
|
||||||
|
return response.ServiceUnavailable(c, "AI service is not configured")
|
||||||
|
case errors.Is(err, ErrEmptyTenderBatch), errors.Is(err, ErrEmptyLanguages):
|
||||||
|
return response.BadRequest(c, "Invalid request", err.Error())
|
||||||
|
case errors.Is(err, ai_summarizer.ErrPipelineJobRunning):
|
||||||
|
return response.Conflict(c, "A pipeline job is already running")
|
||||||
|
case errors.Is(err, ai_summarizer.ErrObjectNotFound):
|
||||||
|
return response.NotFound(c, "Tender not found in AI service")
|
||||||
|
default:
|
||||||
|
if errors.Is(err, ai_summarizer.ErrAPINonSuccess) {
|
||||||
|
return c.JSON(http.StatusBadGateway, response.APIResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: action + " failed: upstream AI service error",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return response.InternalServerError(c, action+" failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
package ai_pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"tm/pkg/ai_summarizer"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client defines Opplens AI operations exposed to the admin API.
|
||||||
|
type Client interface {
|
||||||
|
ScrapeDocuments(ctx context.Context, reqBody ai_summarizer.ScrapeDocumentsRequest) (*ai_summarizer.ScrapeDocumentsResponse, error)
|
||||||
|
ScrapeDocumentsBatch(ctx context.Context, tenders []ai_summarizer.TenderRef) (*ai_summarizer.BatchAcceptedResponse, error)
|
||||||
|
TriggerSummarizeBatch(ctx context.Context, tenders []ai_summarizer.TenderRef) (*ai_summarizer.BatchAcceptedResponse, error)
|
||||||
|
TriggerTranslateBatch(ctx context.Context, tenders []ai_summarizer.TenderRef, languages []string) (*ai_summarizer.BatchAcceptedResponse, error)
|
||||||
|
PipelineSync(ctx context.Context) (*ai_summarizer.PipelineSyncResponse, error)
|
||||||
|
PipelineRun(ctx context.Context, reqBody ai_summarizer.PipelineRunRequest) (map[string]interface{}, error)
|
||||||
|
PipelineRunBatch(ctx context.Context, tenders []ai_summarizer.TenderRef, languages []string) (*ai_summarizer.BatchAcceptedResponse, error)
|
||||||
|
PipelineAnalyze(ctx context.Context) (*ai_summarizer.PipelineActionResponse, error)
|
||||||
|
PipelineDailyRun(ctx context.Context) (*ai_summarizer.PipelineStartedResponse, error)
|
||||||
|
PipelineAuto(ctx context.Context) (*ai_summarizer.PipelineStartedResponse, error)
|
||||||
|
GetPipelineLastRun(ctx context.Context) (*ai_summarizer.PipelineReportResponse, error)
|
||||||
|
GetPipelineLastAutoRun(ctx context.Context) (*ai_summarizer.PipelineReportResponse, error)
|
||||||
|
GetPipelineReport(ctx context.Context, window string) (*ai_summarizer.PipelineReportResponse, error)
|
||||||
|
GetPipelineStats(ctx context.Context, window string) (*ai_summarizer.PipelineStatsResponse, error)
|
||||||
|
GetPipelineMinioStats(ctx context.Context) (ai_summarizer.PipelineMinioStatsResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service proxies admin pipeline operations to the Opplens AI service.
|
||||||
|
type Service interface {
|
||||||
|
ScrapeDocuments(ctx context.Context, form ScrapeDocumentsForm) (*ai_summarizer.ScrapeDocumentsResponse, error)
|
||||||
|
ScrapeDocumentsBatch(ctx context.Context, form TenderBatchForm) (*ai_summarizer.BatchAcceptedResponse, error)
|
||||||
|
SummarizeBatch(ctx context.Context, form TenderBatchForm) (*ai_summarizer.BatchAcceptedResponse, error)
|
||||||
|
TranslateBatch(ctx context.Context, form TranslateBatchForm) (*ai_summarizer.BatchAcceptedResponse, error)
|
||||||
|
Sync(ctx context.Context) (*ai_summarizer.PipelineSyncResponse, error)
|
||||||
|
Run(ctx context.Context, form PipelineRunForm) (map[string]interface{}, error)
|
||||||
|
RunBatch(ctx context.Context, form PipelineRunBatchForm) (*ai_summarizer.BatchAcceptedResponse, error)
|
||||||
|
Analyze(ctx context.Context) (*ai_summarizer.PipelineActionResponse, error)
|
||||||
|
DailyRun(ctx context.Context) (*ai_summarizer.PipelineStartedResponse, error)
|
||||||
|
Auto(ctx context.Context) (*ai_summarizer.PipelineStartedResponse, error)
|
||||||
|
GetLastRun(ctx context.Context) (*ai_summarizer.PipelineReportResponse, error)
|
||||||
|
GetLastAutoRun(ctx context.Context) (*ai_summarizer.PipelineReportResponse, error)
|
||||||
|
GetReport(ctx context.Context, window string) (*ai_summarizer.PipelineReportResponse, error)
|
||||||
|
GetStats(ctx context.Context, window string) (*ai_summarizer.PipelineStatsResponse, error)
|
||||||
|
GetMinioStats(ctx context.Context) (ai_summarizer.PipelineMinioStatsResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type service struct {
|
||||||
|
client Client
|
||||||
|
logger logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates an AI pipeline admin service.
|
||||||
|
func NewService(client Client, log logger.Logger) Service {
|
||||||
|
return &service{
|
||||||
|
client: client,
|
||||||
|
logger: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) requireClient() error {
|
||||||
|
if s.client == nil {
|
||||||
|
return ErrAINotConfigured
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) ScrapeDocuments(ctx context.Context, form ScrapeDocumentsForm) (*ai_summarizer.ScrapeDocumentsResponse, error) {
|
||||||
|
if err := s.requireClient(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Admin scrape documents requested", map[string]interface{}{
|
||||||
|
"contract_folder_id": form.ContractFolderID,
|
||||||
|
"notice_publication_id": form.NoticePublicationID,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := s.client.ScrapeDocuments(ctx, ai_summarizer.ScrapeDocumentsRequest{
|
||||||
|
ContractFolderID: form.ContractFolderID,
|
||||||
|
NoticePublicationID: form.NoticePublicationID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Admin scrape documents failed", map[string]interface{}{
|
||||||
|
"contract_folder_id": form.ContractFolderID,
|
||||||
|
"notice_publication_id": form.NoticePublicationID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("scrape documents failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) ScrapeDocumentsBatch(ctx context.Context, form TenderBatchForm) (*ai_summarizer.BatchAcceptedResponse, error) {
|
||||||
|
if err := s.requireClient(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
refs, err := validateTenderBatch(form.Tenders)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Admin scrape documents batch requested", map[string]interface{}{
|
||||||
|
"tender_count": len(refs),
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := s.client.ScrapeDocumentsBatch(ctx, refs)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Admin scrape documents batch failed", map[string]interface{}{
|
||||||
|
"tender_count": len(refs),
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("scrape documents batch failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) SummarizeBatch(ctx context.Context, form TenderBatchForm) (*ai_summarizer.BatchAcceptedResponse, error) {
|
||||||
|
if err := s.requireClient(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
refs, err := validateTenderBatch(form.Tenders)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Admin summarize batch requested", map[string]interface{}{
|
||||||
|
"tender_count": len(refs),
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := s.client.TriggerSummarizeBatch(ctx, refs)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Admin summarize batch failed", map[string]interface{}{
|
||||||
|
"tender_count": len(refs),
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("summarize batch failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) TranslateBatch(ctx context.Context, form TranslateBatchForm) (*ai_summarizer.BatchAcceptedResponse, error) {
|
||||||
|
if err := s.requireClient(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
refs, err := validateTenderBatch(form.Tenders)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
languages := normalizeLanguages(form.Languages)
|
||||||
|
if len(languages) == 0 {
|
||||||
|
return nil, ErrEmptyLanguages
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Admin translate batch requested", map[string]interface{}{
|
||||||
|
"tender_count": len(refs),
|
||||||
|
"languages": languages,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := s.client.TriggerTranslateBatch(ctx, refs, languages)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Admin translate batch failed", map[string]interface{}{
|
||||||
|
"tender_count": len(refs),
|
||||||
|
"languages": languages,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("translate batch failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) Sync(ctx context.Context) (*ai_summarizer.PipelineSyncResponse, error) {
|
||||||
|
if err := s.requireClient(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Admin pipeline sync requested", map[string]interface{}{})
|
||||||
|
|
||||||
|
resp, err := s.client.PipelineSync(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Admin pipeline sync failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("pipeline sync failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) Run(ctx context.Context, form PipelineRunForm) (map[string]interface{}, error) {
|
||||||
|
if err := s.requireClient(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
languages := normalizeLanguages(form.Languages)
|
||||||
|
if len(languages) == 0 {
|
||||||
|
return nil, ErrEmptyLanguages
|
||||||
|
}
|
||||||
|
|
||||||
|
req := ai_summarizer.PipelineRunRequest{
|
||||||
|
ContractFolderID: form.ContractFolderID,
|
||||||
|
NoticePublicationID: form.NoticePublicationID,
|
||||||
|
Languages: languages,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Admin pipeline run requested", map[string]interface{}{
|
||||||
|
"contract_folder_id": req.ContractFolderID,
|
||||||
|
"notice_publication_id": req.NoticePublicationID,
|
||||||
|
"languages": languages,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := s.client.PipelineRun(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Admin pipeline run failed", map[string]interface{}{
|
||||||
|
"contract_folder_id": req.ContractFolderID,
|
||||||
|
"notice_publication_id": req.NoticePublicationID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("pipeline run failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) RunBatch(ctx context.Context, form PipelineRunBatchForm) (*ai_summarizer.BatchAcceptedResponse, error) {
|
||||||
|
if err := s.requireClient(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
refs, err := validateTenderBatch(form.Tenders)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
languages := normalizeLanguages(form.Languages)
|
||||||
|
if len(languages) == 0 {
|
||||||
|
return nil, ErrEmptyLanguages
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Admin pipeline run batch requested", map[string]interface{}{
|
||||||
|
"tender_count": len(refs),
|
||||||
|
"languages": languages,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := s.client.PipelineRunBatch(ctx, refs, languages)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Admin pipeline run batch failed", map[string]interface{}{
|
||||||
|
"tender_count": len(refs),
|
||||||
|
"languages": languages,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("pipeline run batch failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) Analyze(ctx context.Context) (*ai_summarizer.PipelineActionResponse, error) {
|
||||||
|
if err := s.requireClient(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Admin pipeline analyze requested", map[string]interface{}{})
|
||||||
|
|
||||||
|
resp, err := s.client.PipelineAnalyze(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Admin pipeline analyze failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("pipeline analyze failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) DailyRun(ctx context.Context) (*ai_summarizer.PipelineStartedResponse, error) {
|
||||||
|
if err := s.requireClient(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Admin pipeline daily-run requested", map[string]interface{}{})
|
||||||
|
|
||||||
|
resp, err := s.client.PipelineDailyRun(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Admin pipeline daily-run failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("pipeline daily-run failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) Auto(ctx context.Context) (*ai_summarizer.PipelineStartedResponse, error) {
|
||||||
|
if err := s.requireClient(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Admin pipeline auto requested", map[string]interface{}{})
|
||||||
|
|
||||||
|
resp, err := s.client.PipelineAuto(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Admin pipeline auto failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("pipeline auto failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) GetLastRun(ctx context.Context) (*ai_summarizer.PipelineReportResponse, error) {
|
||||||
|
if err := s.requireClient(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.client.GetPipelineLastRun(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Admin get pipeline last-run failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("get pipeline last-run failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) GetLastAutoRun(ctx context.Context) (*ai_summarizer.PipelineReportResponse, error) {
|
||||||
|
if err := s.requireClient(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.client.GetPipelineLastAutoRun(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Admin get pipeline last-auto-run failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("get pipeline last-auto-run failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) GetReport(ctx context.Context, window string) (*ai_summarizer.PipelineReportResponse, error) {
|
||||||
|
if err := s.requireClient(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.client.GetPipelineReport(ctx, window)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Admin get pipeline report failed", map[string]interface{}{
|
||||||
|
"window": window,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("get pipeline report failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) GetStats(ctx context.Context, window string) (*ai_summarizer.PipelineStatsResponse, error) {
|
||||||
|
if err := s.requireClient(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.client.GetPipelineStats(ctx, window)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Admin get pipeline stats failed", map[string]interface{}{
|
||||||
|
"window": window,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("get pipeline stats failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) GetMinioStats(ctx context.Context) (ai_summarizer.PipelineMinioStatsResponse, error) {
|
||||||
|
if err := s.requireClient(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.client.GetPipelineMinioStats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Admin get pipeline minio stats failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("get pipeline minio stats failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTenderBatch(forms []TenderRefForm) ([]ai_summarizer.TenderRef, error) {
|
||||||
|
if len(forms) == 0 {
|
||||||
|
return nil, ErrEmptyTenderBatch
|
||||||
|
}
|
||||||
|
return toTenderRefs(forms), nil
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package notification
|
package notification
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"tm/pkg/response"
|
"tm/pkg/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,13 +23,26 @@ type NotificationRequest struct {
|
|||||||
|
|
||||||
// SearchForm represents the form for searching notifications
|
// SearchForm represents the form for searching notifications
|
||||||
type SearchForm struct {
|
type SearchForm struct {
|
||||||
Status string `query:"status" valid:"optional"`
|
Q *string `query:"q" valid:"optional"`
|
||||||
Method string `query:"method" valid:"optional"`
|
Search *string `query:"search" valid:"optional"`
|
||||||
EventType string `query:"event_type" valid:"optional"`
|
Status string `query:"status" valid:"optional"`
|
||||||
Type string `query:"type" 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"`
|
Recipient []string `query:"recipient" json:"recipient" valid:"optional"`
|
||||||
Seen *bool `query:"seen" valid:"optional"`
|
Seen *bool `query:"seen" valid:"optional"`
|
||||||
Priority string `query:"priority" 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 {
|
type NotificationListResponse struct {
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ func (h *NotificationHandler) Send(c echo.Context) error {
|
|||||||
// @Tags Admin-Notification
|
// @Tags Admin-Notification
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce 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 status query string false "Status filter"
|
||||||
// @Param method query string false "Method filter"
|
// @Param method query string false "Method filter"
|
||||||
// @Param event_type query string false "Event type filter"
|
// @Param event_type query string false "Event type filter"
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package notification
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"tm/pkg/logger"
|
"tm/pkg/logger"
|
||||||
@@ -52,7 +54,7 @@ func (n *Notification) GetID() string {
|
|||||||
type Repository interface {
|
type Repository interface {
|
||||||
Create(ctx context.Context, notification *Notification) error
|
Create(ctx context.Context, notification *Notification) error
|
||||||
GetByID(ctx context.Context, id string) (*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
|
Update(ctx context.Context, notification *Notification) error
|
||||||
MarkAsSeen(ctx context.Context, notificationID, userID string) error
|
MarkAsSeen(ctx context.Context, notificationID, userID string) error
|
||||||
MarkAllAsSeen(ctx context.Context, 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
|
// 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{}
|
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 {
|
if len(userIDs) == 1 {
|
||||||
filter["user_id"] = userIDs[0]
|
filter["user_id"] = userIDs[0]
|
||||||
} else if len(userIDs) > 1 {
|
} else if len(userIDs) > 1 {
|
||||||
|
|||||||
@@ -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) {
|
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{}{
|
s.logger.Info("Getting notifications", map[string]interface{}{
|
||||||
|
"search": search,
|
||||||
"status": req.Status,
|
"status": req.Status,
|
||||||
"method": req.Method,
|
"method": req.Method,
|
||||||
"event_type": req.EventType,
|
"event_type": req.EventType,
|
||||||
@@ -195,7 +198,7 @@ func (s *notificationService) GetNotifications(ctx context.Context, req *SearchF
|
|||||||
"offset": pagination.Offset,
|
"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 {
|
if err != nil {
|
||||||
s.logger.Error("Failed to get notifications", map[string]interface{}{
|
s.logger.Error("Failed to get notifications", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ type AISummarizerClient interface {
|
|||||||
FetchSummaryOnDemand(ctx context.Context, reqBody ai_summarizer.SummarizeRequest) (*ai_summarizer.SummarizeResponse, error)
|
FetchSummaryOnDemand(ctx context.Context, reqBody ai_summarizer.SummarizeRequest) (*ai_summarizer.SummarizeResponse, error)
|
||||||
FetchTranslationOnDemand(ctx context.Context, reqBody ai_summarizer.TranslateRequest) (*ai_summarizer.TranslateResponse, error)
|
FetchTranslationOnDemand(ctx context.Context, reqBody ai_summarizer.TranslateRequest) (*ai_summarizer.TranslateResponse, error)
|
||||||
FetchAnalyzeOnDemand(ctx context.Context, reqBody ai_summarizer.AnalyzeRequest) (*ai_summarizer.AnalyzeResponse, error)
|
FetchAnalyzeOnDemand(ctx context.Context, reqBody ai_summarizer.AnalyzeRequest) (*ai_summarizer.AnalyzeResponse, error)
|
||||||
TriggerPipelineTranslate(ctx context.Context, languages []string) (*ai_summarizer.PipelineTranslateResponse, error)
|
TriggerTranslateBatch(ctx context.Context, tenders []ai_summarizer.TenderRef, languages []string) (*ai_summarizer.BatchAcceptedResponse, error)
|
||||||
|
TriggerSummarizeBatch(ctx context.Context, tenders []ai_summarizer.TenderRef) (*ai_summarizer.BatchAcceptedResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AISummarizerStorage defines the interface for fetching summaries from MinIO storage.
|
// AISummarizerStorage defines the interface for fetching summaries from MinIO storage.
|
||||||
@@ -1646,12 +1647,15 @@ func (s *tenderService) resolveTranslation(ctx context.Context, t *Tender, langu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := s.aiSummarizerClient.FetchTranslationOnDemand(ctx, ai_summarizer.TranslateRequest{
|
req := ai_summarizer.NewTranslateRequest(
|
||||||
ContractFolderID: t.ContractFolderID,
|
t.ContractFolderID,
|
||||||
NoticePublicationID: t.NoticePublicationID,
|
t.NoticePublicationID,
|
||||||
Language: language,
|
language,
|
||||||
RequestSource: requestSource,
|
t.Title,
|
||||||
})
|
t.Description,
|
||||||
|
)
|
||||||
|
req.RequestSource = requestSource
|
||||||
|
resp, err := s.aiSummarizerClient.FetchTranslationOnDemand(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", false, err
|
return "", "", false, err
|
||||||
}
|
}
|
||||||
|
|||||||
+386
-26
@@ -125,50 +125,349 @@ func (c *Client) FetchTranslationOnDemand(ctx context.Context, reqBody Translate
|
|||||||
return nil, fmt.Errorf("ai_summarizer: all %d translation attempts failed, last error: %w", c.config.APIRetryCount+1, lastErr)
|
return nil, fmt.Errorf("ai_summarizer: all %d translation attempts failed, last error: %w", c.config.APIRetryCount+1, lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TriggerPipelineTranslate calls POST /pipeline/translate to enqueue batch translation
|
// TriggerTranslateBatch calls POST /ai/translate/batch to enqueue background translation.
|
||||||
// for the given target languages.
|
func (c *Client) TriggerTranslateBatch(ctx context.Context, tenders []TenderRef, languages []string) (*BatchAcceptedResponse, error) {
|
||||||
func (c *Client) TriggerPipelineTranslate(ctx context.Context, languages []string) (*PipelineTranslateResponse, error) {
|
reqBody := TranslateBatchRequest{
|
||||||
reqBody := PipelineTranslateRequest{Languages: languages}
|
Tenders: tenders,
|
||||||
|
Languages: languages,
|
||||||
|
}
|
||||||
|
return c.postBatchAccepted(ctx, "/ai/translate/batch", reqBody, "translate batch", map[string]interface{}{
|
||||||
|
"tender_count": len(tenders),
|
||||||
|
"languages": languages,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerSummarizeBatch calls POST /ai/summarize/batch to enqueue background summarization.
|
||||||
|
func (c *Client) TriggerSummarizeBatch(ctx context.Context, tenders []TenderRef) (*BatchAcceptedResponse, error) {
|
||||||
|
reqBody := SummarizeBatchRequest{Tenders: tenders}
|
||||||
|
return c.postBatchAccepted(ctx, "/ai/summarize/batch", reqBody, "summarize batch", map[string]interface{}{
|
||||||
|
"tender_count": len(tenders),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrapeDocuments calls POST /scrape/documents for one tender (synchronous, cached).
|
||||||
|
func (c *Client) ScrapeDocuments(ctx context.Context, reqBody ScrapeDocumentsRequest) (*ScrapeDocumentsResponse, error) {
|
||||||
jsonBody, err := json.Marshal(reqBody)
|
jsonBody, err := json.Marshal(reqBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ai_summarizer: failed to marshal pipeline translate request: %w", err)
|
return nil, fmt.Errorf("ai_summarizer: failed to marshal scrape request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
url := c.config.APIBaseURL + "/pipeline/translate"
|
url := c.config.APIBaseURL + "/scrape/documents"
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
|
httpResp, bodyBytes, err := c.doRawPost(ctx, url, jsonBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ai_summarizer: failed to create pipeline translate request: %w", err)
|
return nil, err
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
httpResp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ai_summarizer: pipeline translate request error: %w", err)
|
|
||||||
}
|
}
|
||||||
defer httpResp.Body.Close()
|
defer httpResp.Body.Close()
|
||||||
|
|
||||||
bodyBytes, err := io.ReadAll(httpResp.Body)
|
if httpResp.StatusCode == http.StatusNotFound {
|
||||||
if err != nil {
|
return nil, fmt.Errorf("%w: status 404, body: %s", ErrObjectNotFound, string(bodyBytes))
|
||||||
return nil, fmt.Errorf("ai_summarizer: failed to read pipeline translate response: %w", err)
|
|
||||||
}
|
}
|
||||||
if httpResp.StatusCode >= 400 {
|
if httpResp.StatusCode >= 400 {
|
||||||
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
|
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
var result PipelineTranslateResponse
|
var result ScrapeDocumentsResponse
|
||||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||||
return nil, fmt.Errorf("ai_summarizer: failed to decode pipeline translate response: %w", err)
|
return nil, fmt.Errorf("ai_summarizer: failed to decode scrape response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.logger.Info("Pipeline translate triggered", map[string]interface{}{
|
c.logger.Info("AI scrape documents request succeeded", map[string]interface{}{
|
||||||
"status": result.Status,
|
"contract_folder_id": reqBody.ContractFolderID,
|
||||||
"languages": result.Languages,
|
"notice_publication_id": reqBody.NoticePublicationID,
|
||||||
|
"files_downloaded": result.FilesDownloaded,
|
||||||
})
|
})
|
||||||
|
|
||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScrapeDocumentsBatch calls POST /scrape/documents/batch.
|
||||||
|
func (c *Client) ScrapeDocumentsBatch(ctx context.Context, tenders []TenderRef) (*BatchAcceptedResponse, error) {
|
||||||
|
reqBody := ScrapeDocumentsBatchRequest{Tenders: tenders}
|
||||||
|
return c.postBatchAccepted(ctx, "/scrape/documents/batch", reqBody, "scrape batch", map[string]interface{}{
|
||||||
|
"tender_count": len(tenders),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PipelineSync calls POST /pipeline/sync to pull the tender list from the backend.
|
||||||
|
func (c *Client) PipelineSync(ctx context.Context) (*PipelineSyncResponse, error) {
|
||||||
|
url := c.config.APIBaseURL + "/pipeline/sync"
|
||||||
|
httpResp, bodyBytes, err := c.doRawPost(ctx, url, nil)
|
||||||
|
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 PipelineSyncResponse
|
||||||
|
if len(bodyBytes) > 0 {
|
||||||
|
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("ai_summarizer: failed to decode pipeline sync response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("AI pipeline sync completed", map[string]interface{}{
|
||||||
|
"stored": result.Stored,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PipelineRun calls POST /pipeline/run for one tender (scrape → translate → summarize).
|
||||||
|
func (c *Client) PipelineRun(ctx context.Context, reqBody PipelineRunRequest) (map[string]interface{}, error) {
|
||||||
|
jsonBody, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ai_summarizer: failed to marshal pipeline run request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := c.config.APIBaseURL + "/pipeline/run"
|
||||||
|
httpResp, bodyBytes, err := c.doRawPost(ctx, url, jsonBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer httpResp.Body.Close()
|
||||||
|
|
||||||
|
if httpResp.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, fmt.Errorf("%w: status 404, body: %s", ErrObjectNotFound, string(bodyBytes))
|
||||||
|
}
|
||||||
|
if httpResp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
if len(bodyBytes) > 0 {
|
||||||
|
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("ai_summarizer: failed to decode pipeline run response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("AI pipeline run completed", map[string]interface{}{
|
||||||
|
"contract_folder_id": reqBody.ContractFolderID,
|
||||||
|
"notice_publication_id": reqBody.NoticePublicationID,
|
||||||
|
"languages": reqBody.Languages,
|
||||||
|
})
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PipelineRunBatch calls POST /pipeline/run/batch.
|
||||||
|
func (c *Client) PipelineRunBatch(ctx context.Context, tenders []TenderRef, languages []string) (*BatchAcceptedResponse, error) {
|
||||||
|
reqBody := PipelineRunBatchRequest{
|
||||||
|
Tenders: tenders,
|
||||||
|
Languages: languages,
|
||||||
|
}
|
||||||
|
return c.postBatchAccepted(ctx, "/pipeline/run/batch", reqBody, "pipeline run batch", map[string]interface{}{
|
||||||
|
"tender_count": len(tenders),
|
||||||
|
"languages": languages,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PipelineAnalyze calls POST /pipeline/analyze to trigger analysis across stored tenders.
|
||||||
|
func (c *Client) PipelineAnalyze(ctx context.Context) (*PipelineActionResponse, error) {
|
||||||
|
return c.triggerPipelineAction(ctx, "/pipeline/analyze", "pipeline analyze")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PipelineDailyRun calls POST /pipeline/daily-run (sync + full pipeline on newly synced tenders).
|
||||||
|
func (c *Client) PipelineDailyRun(ctx context.Context) (*PipelineStartedResponse, error) {
|
||||||
|
return c.triggerPipelineStarted(ctx, "/pipeline/daily-run", "pipeline daily-run")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PipelineAuto calls POST /pipeline/auto (sync + complete all missing work, idempotent).
|
||||||
|
func (c *Client) PipelineAuto(ctx context.Context) (*PipelineStartedResponse, error) {
|
||||||
|
return c.triggerPipelineStarted(ctx, "/pipeline/auto", "pipeline auto")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPipelineLastRun returns the result of the last daily-run (GET /pipeline/last-run).
|
||||||
|
func (c *Client) GetPipelineLastRun(ctx context.Context) (*PipelineReportResponse, error) {
|
||||||
|
return c.getPipelineReport(ctx, "/pipeline/last-run")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPipelineLastAutoRun returns the result of the last auto run (GET /pipeline/last-auto-run).
|
||||||
|
func (c *Client) GetPipelineLastAutoRun(ctx context.Context) (*PipelineReportResponse, error) {
|
||||||
|
return c.getPipelineReport(ctx, "/pipeline/last-auto-run")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPipelineReport calls GET /pipeline/report.
|
||||||
|
func (c *Client) GetPipelineReport(ctx context.Context, window string) (*PipelineReportResponse, error) {
|
||||||
|
return c.getPipelineReportWithQuery(ctx, "/pipeline/report", window)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPipelineStats calls GET /pipeline/stats.
|
||||||
|
func (c *Client) GetPipelineStats(ctx context.Context, window string) (*PipelineStatsResponse, error) {
|
||||||
|
url := c.config.APIBaseURL + "/pipeline/stats"
|
||||||
|
if window = strings.TrimSpace(window); window != "" {
|
||||||
|
url += "?window=" + window
|
||||||
|
}
|
||||||
|
|
||||||
|
httpResp, bodyBytes, err := c.doRawGet(ctx, url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer httpResp.Body.Close()
|
||||||
|
|
||||||
|
if httpResp.StatusCode == http.StatusBadRequest {
|
||||||
|
return nil, fmt.Errorf("%w: status 400, body: %s", ErrAPINonSuccess, string(bodyBytes))
|
||||||
|
}
|
||||||
|
if httpResp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result PipelineStatsResponse
|
||||||
|
if len(bodyBytes) > 0 {
|
||||||
|
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("ai_summarizer: failed to decode pipeline stats response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPipelineMinioStats calls GET /pipeline/minio-stats.
|
||||||
|
func (c *Client) GetPipelineMinioStats(ctx context.Context) (PipelineMinioStatsResponse, error) {
|
||||||
|
url := c.config.APIBaseURL + "/pipeline/minio-stats"
|
||||||
|
httpResp, bodyBytes, err := c.doRawGet(ctx, url)
|
||||||
|
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 PipelineMinioStatsResponse
|
||||||
|
if len(bodyBytes) > 0 {
|
||||||
|
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("ai_summarizer: failed to decode pipeline minio stats response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) postBatchAccepted(ctx context.Context, path string, reqBody any, label string, fields map[string]interface{}) (*BatchAcceptedResponse, error) {
|
||||||
|
jsonBody, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ai_summarizer: failed to marshal %s request: %w", label, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := c.config.APIBaseURL + path
|
||||||
|
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 BatchAcceptedResponse
|
||||||
|
if len(bodyBytes) > 0 {
|
||||||
|
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("ai_summarizer: failed to decode %s response: %w", label, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logFields := map[string]interface{}{
|
||||||
|
"path": path,
|
||||||
|
"status": result.Status,
|
||||||
|
"count": result.Count,
|
||||||
|
}
|
||||||
|
for k, v := range fields {
|
||||||
|
logFields[k] = v
|
||||||
|
}
|
||||||
|
c.logger.Info("AI batch request accepted", logFields)
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) triggerPipelineStarted(ctx context.Context, path, label string) (*PipelineStartedResponse, error) {
|
||||||
|
url := c.config.APIBaseURL + path
|
||||||
|
httpResp, bodyBytes, err := c.doRawPost(ctx, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer httpResp.Body.Close()
|
||||||
|
|
||||||
|
if httpResp.StatusCode == http.StatusConflict {
|
||||||
|
return nil, fmt.Errorf("%w: status 409, body: %s", ErrPipelineJobRunning, string(bodyBytes))
|
||||||
|
}
|
||||||
|
if httpResp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result PipelineStartedResponse
|
||||||
|
if len(bodyBytes) > 0 {
|
||||||
|
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("ai_summarizer: failed to decode %s response: %w", label, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("AI pipeline job started", map[string]interface{}{
|
||||||
|
"path": path,
|
||||||
|
"status": result.Status,
|
||||||
|
"report": result.Report,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getPipelineReport(ctx context.Context, path string) (*PipelineReportResponse, error) {
|
||||||
|
return c.getPipelineReportWithQuery(ctx, path, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getPipelineReportWithQuery(ctx context.Context, path, window string) (*PipelineReportResponse, error) {
|
||||||
|
url := c.config.APIBaseURL + path
|
||||||
|
if window = strings.TrimSpace(window); window != "" {
|
||||||
|
url += "?window=" + window
|
||||||
|
}
|
||||||
|
|
||||||
|
httpResp, bodyBytes, err := c.doRawGet(ctx, url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer httpResp.Body.Close()
|
||||||
|
|
||||||
|
if httpResp.StatusCode == http.StatusBadRequest {
|
||||||
|
return nil, fmt.Errorf("%w: status 400, body: %s", ErrAPINonSuccess, string(bodyBytes))
|
||||||
|
}
|
||||||
|
if httpResp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result PipelineReportResponse
|
||||||
|
if len(bodyBytes) > 0 {
|
||||||
|
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("ai_summarizer: failed to decode pipeline report response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doRawGet(ctx context.Context, url string) (*http.Response, []byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("ai_summarizer: failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
httpResp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("ai_summarizer: request error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(httpResp.Body)
|
||||||
|
if err != nil {
|
||||||
|
httpResp.Body.Close()
|
||||||
|
return nil, nil, fmt.Errorf("ai_summarizer: failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
return httpResp, bodyBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
// FetchAnalyzeOnDemand calls POST /ai/analyze for one tender.
|
// FetchAnalyzeOnDemand calls POST /ai/analyze for one tender.
|
||||||
func (c *Client) FetchAnalyzeOnDemand(ctx context.Context, reqBody AnalyzeRequest) (*AnalyzeResponse, error) {
|
func (c *Client) FetchAnalyzeOnDemand(ctx context.Context, reqBody AnalyzeRequest) (*AnalyzeResponse, error) {
|
||||||
jsonBody, err := json.Marshal(reqBody)
|
jsonBody, err := json.Marshal(reqBody)
|
||||||
@@ -204,9 +503,70 @@ func (c *Client) FetchAnalyzeOnDemand(ctx context.Context, reqBody AnalyzeReques
|
|||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TriggerPipelineSummarize calls POST /pipeline/summarize.
|
// StartOnboarding calls POST /onboarding to start company profile indexing for recommendations.
|
||||||
func (c *Client) TriggerPipelineSummarize(ctx context.Context) (*PipelineActionResponse, error) {
|
func (c *Client) StartOnboarding(ctx context.Context, reqBody OnboardingRequest) (*OnboardingResponse, error) {
|
||||||
return c.triggerPipelineAction(ctx, "/pipeline/summarize", "pipeline summarize")
|
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) {
|
||||||
|
|||||||
@@ -4,6 +4,20 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TenderRef identifies a tender for batch and pipeline requests.
|
||||||
|
type TenderRef struct {
|
||||||
|
ContractFolderID string `json:"contract_folder_id"`
|
||||||
|
NoticePublicationID string `json:"notice_publication_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTenderRef builds a TenderRef from contract folder and notice publication ids.
|
||||||
|
func NewTenderRef(contractFolderID, noticePublicationID string) TenderRef {
|
||||||
|
return TenderRef{
|
||||||
|
ContractFolderID: strings.TrimSpace(contractFolderID),
|
||||||
|
NoticePublicationID: strings.TrimSpace(noticePublicationID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SummarizeRequest is the payload for POST /ai/summarize.
|
// SummarizeRequest is the payload for POST /ai/summarize.
|
||||||
type SummarizeRequest struct {
|
type SummarizeRequest struct {
|
||||||
ContractFolderID string `json:"contract_folder_id"`
|
ContractFolderID string `json:"contract_folder_id"`
|
||||||
@@ -70,9 +84,22 @@ type TranslateRequest struct {
|
|||||||
ContractFolderID string `json:"contract_folder_id"`
|
ContractFolderID string `json:"contract_folder_id"`
|
||||||
NoticePublicationID string `json:"notice_publication_id"`
|
NoticePublicationID string `json:"notice_publication_id"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
RequestSource string `json:"-"` // internal: daily_job | manual_trigger
|
RequestSource string `json:"-"` // internal: daily_job | manual_trigger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewTranslateRequest builds a TranslateRequest for POST /ai/translate.
|
||||||
|
func NewTranslateRequest(contractFolderID, noticePublicationID, language, title, description string) TranslateRequest {
|
||||||
|
return TranslateRequest{
|
||||||
|
ContractFolderID: strings.TrimSpace(contractFolderID),
|
||||||
|
NoticePublicationID: strings.TrimSpace(noticePublicationID),
|
||||||
|
Language: strings.ToLower(strings.TrimSpace(language)),
|
||||||
|
Title: strings.TrimSpace(title),
|
||||||
|
Description: strings.TrimSpace(description),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
TranslationRequestSourceDailyJob = "daily_job"
|
TranslationRequestSourceDailyJob = "daily_job"
|
||||||
TranslationRequestSourceManualTrigger = "manual_trigger"
|
TranslationRequestSourceManualTrigger = "manual_trigger"
|
||||||
@@ -122,15 +149,69 @@ type AnalyzeResponse struct {
|
|||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PipelineTranslateRequest represents the request payload for POST /pipeline/translate.
|
// ScrapeDocumentsRequest is the payload for POST /scrape/documents.
|
||||||
type PipelineTranslateRequest struct {
|
type ScrapeDocumentsRequest struct {
|
||||||
Languages []string `json:"languages"`
|
ContractFolderID string `json:"contract_folder_id"`
|
||||||
|
NoticePublicationID string `json:"notice_publication_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PipelineTranslateResponse represents the response from POST /pipeline/translate.
|
// ScrapeDocumentsFile describes one downloaded document.
|
||||||
type PipelineTranslateResponse struct {
|
type ScrapeDocumentsFile struct {
|
||||||
Status string `json:"status"`
|
Filename string `json:"filename"`
|
||||||
Languages []string `json:"languages"`
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrapeDocumentsResponse is returned by POST /scrape/documents.
|
||||||
|
type ScrapeDocumentsResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
FilesDownloaded int `json:"files_downloaded"`
|
||||||
|
Files []ScrapeDocumentsFile `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrapeDocumentsBatchRequest is the payload for POST /scrape/documents/batch.
|
||||||
|
type ScrapeDocumentsBatchRequest struct {
|
||||||
|
Tenders []TenderRef `json:"tenders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SummarizeBatchRequest is the payload for POST /ai/summarize/batch.
|
||||||
|
type SummarizeBatchRequest struct {
|
||||||
|
Tenders []TenderRef `json:"tenders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranslateBatchRequest is the payload for POST /ai/translate/batch.
|
||||||
|
type TranslateBatchRequest struct {
|
||||||
|
Tenders []TenderRef `json:"tenders"`
|
||||||
|
Languages []string `json:"languages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchAcceptedResponse is returned by background batch endpoints (HTTP 202).
|
||||||
|
type BatchAcceptedResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PipelineSyncResponse is returned by POST /pipeline/sync.
|
||||||
|
type PipelineSyncResponse struct {
|
||||||
|
Stored int `json:"stored"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PipelineRunRequest is the payload for POST /pipeline/run.
|
||||||
|
type PipelineRunRequest struct {
|
||||||
|
ContractFolderID string `json:"contract_folder_id"`
|
||||||
|
NoticePublicationID string `json:"notice_publication_id"`
|
||||||
|
Languages []string `json:"languages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PipelineRunBatchRequest is the payload for POST /pipeline/run/batch.
|
||||||
|
type PipelineRunBatchRequest struct {
|
||||||
|
Tenders []TenderRef `json:"tenders"`
|
||||||
|
Languages []string `json:"languages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PipelineStartedResponse is returned by POST /pipeline/daily-run and POST /pipeline/auto.
|
||||||
|
type PipelineStartedResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Report string `json:"report"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PipelineActionResponse is returned by fire-and-forget pipeline endpoints.
|
// PipelineActionResponse is returned by fire-and-forget pipeline endpoints.
|
||||||
@@ -138,6 +219,55 @@ type PipelineActionResponse struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PipelineReportQuery holds optional query params for GET /pipeline/report and GET /pipeline/stats.
|
||||||
|
type PipelineReportQuery struct {
|
||||||
|
Window string `json:"window,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PipelineReportResponse is returned by GET /pipeline/report.
|
||||||
|
type PipelineReportResponse struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Summary map[string]interface{} `json:"summary,omitempty"`
|
||||||
|
Days map[string]interface{} `json:"days,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PipelineStatsResponse is returned by GET /pipeline/stats.
|
||||||
|
type PipelineStatsResponse struct {
|
||||||
|
GeneratedAt string `json:"generated_at"`
|
||||||
|
EventLog map[string]interface{} `json:"event_log"`
|
||||||
|
Minio map[string]interface{} `json:"minio"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PipelineMinioStatsResponse is returned by GET /pipeline/minio-stats.
|
||||||
|
type PipelineMinioStatsResponse map[string]interface{}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
|||||||
@@ -33,4 +33,7 @@ var (
|
|||||||
|
|
||||||
// ErrAPINonSuccess is returned when the AI API returns a non-2xx status code.
|
// ErrAPINonSuccess is returned when the AI API returns a non-2xx status code.
|
||||||
ErrAPINonSuccess = errors.New("ai_summarizer: API returned non-success status")
|
ErrAPINonSuccess = errors.New("ai_summarizer: API returned non-success status")
|
||||||
|
|
||||||
|
// ErrPipelineJobRunning is returned when a single-flight pipeline job is already running (HTTP 409).
|
||||||
|
ErrPipelineJobRunning = errors.New("ai_summarizer: pipeline job already running")
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user