Compare commits

...

2 Commits

Author SHA1 Message Date
Mazyar e31bccced6 Add unit tests for dashboard repository and enhance BSON handling
continuous-integration/drone/push Build is passing
2026-06-21 10:03:17 +03:30
Mazyar 45cfa24a72 Enhance document scraper service with AI portal integration and error handling
- Updated the document scraper service to include a new ScrapePortalsProvider interface, allowing for dynamic retrieval of supported scraping portals.
- Modified the ListPendingTenders and GetTenderByNoticeID methods to filter tenders based on document URLs that match the configured portals.
- Introduced new error handling for cases when the scrape portals provider is not configured, returning appropriate service unavailable responses.
- Enhanced API documentation to reflect changes in tender retrieval logic and added error response details for unsupported portal scenarios.

This update improves the document scraping functionality by integrating AI portal support, enhancing the overall reliability and flexibility of the tender management system.
2026-06-21 09:58:11 +03:30
12 changed files with 257 additions and 73 deletions
+1 -1
View File
@@ -231,7 +231,7 @@ func main() {
contactService := contact.NewService(contactRepository, logger, notificationSDK)
cmsService := cms.NewService(cmsRepository, logger)
kanbanService := kanban.NewService(boardRepository, columnRepository, cardRepository, goRulesClient, conf.GoRules.RuleID, logger)
documentScraperService := document_scraper.NewService(tenderRepository, logger)
documentScraperService := document_scraper.NewService(tenderRepository, aiPipelineClient, logger)
dashboardRepository := dashboard.NewRepository(mongoManager, logger, procedureDocumentsLister)
dashboardService := dashboard.NewService(dashboardRepository, logger)
aiPipelineService := ai_pipeline.NewService(aiPipelineClient, logger, tenderService)
+39 -11
View File
@@ -149,24 +149,20 @@ func (r *repository) Summary(ctx context.Context, closingWindowSec int64) (*Summ
}
func facetCount(facet bson.M, key string) int64 {
rows, ok := facet[key].(bson.A)
if !ok || len(rows) == 0 {
rows := facetRows(facet, key)
if len(rows) == 0 {
return 0
}
row, ok := rows[0].(bson.M)
if !ok {
return 0
}
return aggregateCount(row, "count")
return aggregateCount(asBSONMap(rows[0]), "count")
}
func facetCurrencyValue(facet bson.M) (string, float64) {
rows, ok := facet["currency_value"].(bson.A)
if !ok || len(rows) == 0 {
rows := facetRows(facet, "currency_value")
if len(rows) == 0 {
return defaultValueCurrency, 0
}
row, ok := rows[0].(bson.M)
if !ok {
row := asBSONMap(rows[0])
if row == nil {
return defaultValueCurrency, 0
}
@@ -177,6 +173,38 @@ func facetCurrencyValue(facet bson.M) (string, float64) {
return currency, toFloat64(row["total"])
}
func facetRows(facet bson.M, key string) []interface{} {
raw, ok := facet[key]
if !ok {
return nil
}
switch rows := raw.(type) {
case bson.A:
return []interface{}(rows)
case []interface{}:
return rows
default:
return nil
}
}
func asBSONMap(v interface{}) bson.M {
switch doc := v.(type) {
case bson.M:
return doc
case bson.D:
out := make(bson.M, len(doc))
for _, elem := range doc {
out[elem.Key] = elem.Value
}
return out
case map[string]interface{}:
return doc
default:
return nil
}
}
func (r *repository) Trend(ctx context.Context, days int, metric string, startUnix int64) (map[string]int64, error) {
field := trendTimestampField(metric)
+44
View File
@@ -0,0 +1,44 @@
package dashboard
import (
"testing"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestFacetCountDecodesBSOND(t *testing.T) {
facet := bson.M{
"total": bson.A{
bson.D{{Key: "count", Value: int32(42)}},
},
"active": bson.A{
bson.D{{Key: "count", Value: int64(7)}},
},
}
if got := facetCount(facet, "total"); got != 42 {
t.Fatalf("expected total 42, got %d", got)
}
if got := facetCount(facet, "active"); got != 7 {
t.Fatalf("expected active 7, got %d", got)
}
}
func TestFacetCurrencyValueDecodesBSOND(t *testing.T) {
facet := bson.M{
"currency_value": bson.A{
bson.D{
{Key: "_id", Value: "eur"},
{Key: "total", Value: float64(12345.67)},
},
},
}
currency, total := facetCurrencyValue(facet)
if currency != "EUR" {
t.Fatalf("expected EUR, got %q", currency)
}
if total != 12345.67 {
t.Fatalf("expected 12345.67, got %f", total)
}
}
+2 -2
View File
@@ -78,7 +78,7 @@ func (s *service) Summary(ctx context.Context, query SummaryQuery) (*SummaryResp
"closing_window_hours": windowHours,
})
result, err := s.repo.Summary(ctx, windowSec)
result, err := s.repo.Summary(context.WithoutCancel(ctx), windowSec)
if err != nil {
s.logger.Error("Failed to fetch dashboard summary", map[string]interface{}{
"error": err.Error(),
@@ -215,7 +215,7 @@ func (s *service) Statistics(ctx context.Context, query StatisticsQuery) (*Stati
"days": days,
})
result, err := s.repo.Statistics(ctx, days, startUnix)
result, err := s.repo.Statistics(context.WithoutCancel(ctx), days, startUnix)
if err != nil {
s.logger.Error("Failed to fetch dashboard statistics report", map[string]interface{}{
"error": err.Error(),
+23 -12
View File
@@ -20,6 +20,12 @@ const noticesCollectionName = "notices"
// keeping counts aligned with the tender panel (MinIO is the source of truth for scraped docs).
const scrapedScopeCacheTTL = 5 * time.Minute
const (
scrapedScopeResolveTimeout = 2 * time.Minute
statisticsQueryTimeout = 2 * time.Minute
maxScrapedFolderInClause = 5000
)
// scrapedTendersScope is the resolved Mongo filter for tenders with scraped documents.
// zero is set when MinIO reports no procedure folders; callers must return zero without querying.
type scrapedTendersScope struct {
@@ -28,12 +34,15 @@ type scrapedTendersScope struct {
}
func (r *repository) Statistics(ctx context.Context, days int, startUnix int64) (*StatisticsReportResponse, error) {
queryCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), statisticsQueryTimeout)
defer cancel()
now := time.Now().UTC()
endDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
startDay := endDay.AddDate(0, 0, -(days - 1))
// Resolve scraped-document scope from MinIO (cached); falls back to Mongo flags on error.
scrapedScope, err := r.cachedScrapedTendersScope(ctx)
scrapedScope, err := r.cachedScrapedTendersScope(queryCtx)
if err != nil {
return nil, err
}
@@ -46,7 +55,7 @@ func (r *repository) Statistics(ctx context.Context, days int, startUnix int64)
totalTranslated int64
)
g, gctx := errgroup.WithContext(ctx)
g, gctx := errgroup.WithContext(queryCtx)
g.Go(func() error {
// Use created_at (first ingest time). processing_metadata.scraped_at is reset on
@@ -221,7 +230,10 @@ func (r *repository) cachedScrapedTendersScope(ctx context.Context) (scrapedTend
}
r.scrapedScopeMu.Unlock()
scope, resolveErr := r.resolveScrapedTendersScope(ctx)
resolveCtx, cancel := context.WithTimeout(context.Background(), scrapedScopeResolveTimeout)
defer cancel()
scope, resolveErr := r.resolveScrapedTendersScope(resolveCtx)
if resolveErr != nil {
r.logger.Warn("MinIO scraped scope unavailable, using Mongo fallback for dashboard statistics", map[string]interface{}{
"error": resolveErr.Error(),
@@ -250,19 +262,18 @@ func (r *repository) resolveScrapedTendersScope(ctx context.Context) (scrapedTen
return scrapedTendersScope{}, err
}
if len(folderIDs) > 0 {
if len(folderIDs) > maxScrapedFolderInClause {
r.logger.Warn("Too many MinIO procedure folders for dashboard statistics, using Mongo fallback", map[string]interface{}{
"folder_count": len(folderIDs),
"max_in_clause": maxScrapedFolderInClause,
})
return mongoScrapedTendersScope(), nil
}
return scrapedTendersScope{
match: bson.M{"contract_folder_id": bson.M{"$in": folderIDs}},
}, nil
}
if r.procedureLister != nil {
return scrapedTendersScope{zero: true}, nil
}
return scrapedTendersScope{
match: bson.M{
"processing_metadata.documents_scraped": true,
"scraped_documents": bson.M{"$exists": true, "$ne": bson.A{}},
},
}, nil
return mongoScrapedTendersScope(), nil
}
func (r *repository) scrapedDocumentsFolderIDs(ctx context.Context) ([]string, error) {
@@ -101,7 +101,7 @@ func TestResolveScrapedTendersScopeUsesMinIOFolders(t *testing.T) {
}
}
func TestResolveScrapedTendersScopeZeroWhenMinIOEmpty(t *testing.T) {
func TestResolveScrapedTendersScopeMongoFallbackWhenMinIOEmpty(t *testing.T) {
repo := &repository{
procedureLister: &mockProcedureDocumentsLister{},
}
@@ -110,8 +110,11 @@ func TestResolveScrapedTendersScopeZeroWhenMinIOEmpty(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !scope.zero {
t.Fatalf("expected zero scope, got %#v", scope)
if scope.zero {
t.Fatal("expected mongo fallback scope, not zero scope")
}
if scope.match["processing_metadata.documents_scraped"] != true {
t.Fatalf("expected mongo fallback filter, got %#v", scope.match)
}
}
+2
View File
@@ -5,4 +5,6 @@ import "errors"
var (
// ErrInvalidDateRange is returned when created_at_from is after created_at_to.
ErrInvalidDateRange = errors.New("invalid date range: created_at_from must be before or equal to created_at_to")
// ErrScrapePortalsUnavailable is returned when the scrape portals provider is not configured.
ErrScrapePortalsUnavailable = errors.New("scrape portals provider is not configured")
)
+10 -2
View File
@@ -24,7 +24,7 @@ func NewHandler(service Service, logger logger.Logger) *Handler {
// ListPendingTenders retrieves tenders that need document scraping
// @Summary List pending tenders for document scraping
// @Description Retrieve tenders that have not yet been scraped for documents. Each item includes contract_folder_id, notice_publication_id, document_url, title, and description. Optionally filter by created_at range (Unix timestamps in seconds). By default only tenders with active deadlines are returned; set include_expired=true to include expired tenders.
// @Description Retrieve tenders that have not yet been scraped for documents and whose document URL matches a portal supported by the AI scraping service. Each item includes contract_folder_id, notice_publication_id, document_url, title, and description. Optionally filter by created_at range (Unix timestamps in seconds). By default only tenders with active deadlines are returned; set include_expired=true to include expired tenders.
// @Tags Document-Scraper
// @Produce json
// @Param limit query int false "Number of items per page (default: 20, max: 100)"
@@ -35,6 +35,7 @@ func NewHandler(service Service, logger logger.Logger) *Handler {
// @Success 200 {object} response.APIResponse{data=DocumentScraperListResponse} "List of pending tenders"
// @Failure 400 {object} response.APIResponse "Invalid query parameters"
// @Failure 401 {object} response.APIResponse "Invalid or missing API key"
// @Failure 503 {object} response.APIResponse "Scrape portals service is not configured"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Security ApiKeyAuth
// @Router /api/v1/scraper/tenders [get]
@@ -49,6 +50,9 @@ func (h *Handler) ListPendingTenders(c echo.Context) error {
if errors.Is(err, ErrInvalidDateRange) {
return response.BadRequest(c, "Invalid date range", err.Error())
}
if errors.Is(err, ErrScrapePortalsUnavailable) {
return response.ServiceUnavailable(c, "Scrape portals service is not configured")
}
return response.InternalServerError(c, "Failed to list pending tenders")
}
@@ -57,7 +61,7 @@ func (h *Handler) ListPendingTenders(c echo.Context) error {
// GetTenderByNoticeID retrieves a specific tender by its notice publication ID
// @Summary Get tender by notice ID for document scraping
// @Description Retrieve a specific tender by its notice publication ID (contract_folder_id, notice_publication_id, document_url, title, description).
// @Description Retrieve a specific unexpired tender whose document URL matches a supported scrape portal.
// @Tags Document-Scraper
// @Produce json
// @Param notice_id path string true "Notice Publication ID"
@@ -65,6 +69,7 @@ func (h *Handler) ListPendingTenders(c echo.Context) error {
// @Failure 400 {object} response.APIResponse "Invalid notice ID"
// @Failure 401 {object} response.APIResponse "Invalid or missing API key"
// @Failure 404 {object} response.APIResponse "Tender not found"
// @Failure 503 {object} response.APIResponse "Scrape portals service is not configured"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Security ApiKeyAuth
// @Router /api/v1/scraper/tenders/{notice_id} [get]
@@ -76,6 +81,9 @@ func (h *Handler) GetTenderByNoticeID(c echo.Context) error {
tender, err := h.service.GetTenderByNoticeID(c.Request().Context(), noticeID)
if err != nil {
if errors.Is(err, ErrScrapePortalsUnavailable) {
return response.ServiceUnavailable(c, "Scrape portals service is not configured")
}
if err.Error() == "tender not found" {
return response.NotFound(c, "Tender not found")
}
+33
View File
@@ -0,0 +1,33 @@
package document_scraper
import (
"strings"
"tm/internal/tender"
)
// documentURL returns the URL used for portal matching and the scraper API payload.
func documentURL(t *tender.Tender) string {
if t == nil {
return ""
}
if u := strings.TrimSpace(t.DocumentURI); u != "" {
return u
}
return strings.TrimSpace(t.TenderURL)
}
// matchesScrapePortals reports whether url contains any supported portal identifier.
func matchesScrapePortals(url string, portals []string) bool {
if url == "" || len(portals) == 0 {
return false
}
urlLower := strings.ToLower(url)
for _, portal := range portals {
p := strings.TrimSpace(portal)
if p != "" && strings.Contains(urlLower, strings.ToLower(p)) {
return true
}
}
return false
}
+68 -26
View File
@@ -3,21 +3,17 @@ package document_scraper
import (
"context"
"fmt"
"strings"
"time"
"tm/internal/tender"
"tm/pkg/ai_summarizer"
"tm/pkg/logger"
"tm/pkg/response"
)
var documentScraperCountryCodes = []string{"SWE", "FIN", "NOR"}
func isDocumentScraperCountry(countryCode string) bool {
for _, code := range documentScraperCountryCodes {
if countryCode == code {
return true
}
}
return false
// ScrapePortalsProvider fetches supported document scraping portal identifiers.
type ScrapePortalsProvider interface {
GetScrapePortals(ctx context.Context) (*ai_summarizer.ScrapePortalsResponse, error)
}
// Service defines business logic for the document scraper API
@@ -29,21 +25,49 @@ type Service interface {
}
type service struct {
tenderRepo tender.TenderRepository
logger logger.Logger
tenderRepo tender.TenderRepository
portalsProvider ScrapePortalsProvider
logger logger.Logger
}
// NewService creates a new document scraper service
func NewService(tenderRepo tender.TenderRepository, logger logger.Logger) Service {
func NewService(tenderRepo tender.TenderRepository, portalsProvider ScrapePortalsProvider, logger logger.Logger) Service {
return &service{
tenderRepo: tenderRepo,
logger: logger,
tenderRepo: tenderRepo,
portalsProvider: portalsProvider,
logger: logger,
}
}
func (s *service) fetchScrapePortals(ctx context.Context) ([]string, error) {
if s.portalsProvider == nil {
s.logger.Warn("Scrape portals provider not configured", map[string]interface{}{})
return nil, ErrScrapePortalsUnavailable
}
resp, err := s.portalsProvider.GetScrapePortals(ctx)
if err != nil {
s.logger.Error("Failed to fetch scrape portals", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("fetch scrape portals: %w", err)
}
if resp == nil {
return nil, nil
}
portals := make([]string, 0, len(*resp))
for _, portal := range *resp {
if p := strings.TrimSpace(portal); p != "" {
portals = append(portals, p)
}
}
return portals, nil
}
// ListPendingTenders retrieves tenders that have not yet been scraped for documents.
// Only returns tenders from Sweden, Finland, and Norway. By default only tenders with active
// deadlines are included; set IncludeExpired to include tenders whose deadline has passed.
// Only returns tenders whose document URL matches a portal supported by the AI service.
// By default only tenders with active deadlines are included; set IncludeExpired to include expired tenders.
func (s *service) ListPendingTenders(ctx context.Context, req DocumentScraperListRequest) (*DocumentScraperListResponse, *response.Meta, error) {
req.Normalize()
@@ -51,16 +75,30 @@ func (s *service) ListPendingTenders(ctx context.Context, req DocumentScraperLis
return nil, nil, ErrInvalidDateRange
}
portals, err := s.fetchScrapePortals(ctx)
if err != nil {
return nil, nil, err
}
if len(portals) == 0 {
s.logger.Info("No scrape portals configured; returning empty pending tender list", map[string]interface{}{})
meta := &response.Meta{Total: 0, Limit: req.Limit, Offset: req.Offset, HasMore: false}
if meta.Limit > 0 {
meta.Page = (req.Offset / meta.Limit) + 1
}
return &DocumentScraperListResponse{Tenders: []DocumentScraperTenderResponse{}}, meta, nil
}
s.logger.Info("Listing pending tenders for document scraper", map[string]interface{}{
"limit": req.Limit,
"offset": req.Offset,
"created_at_from": req.CreatedAtFrom,
"created_at_to": req.CreatedAtTo,
"include_expired": req.IncludeExpired,
"limit": req.Limit,
"offset": req.Offset,
"created_at_from": req.CreatedAtFrom,
"created_at_to": req.CreatedAtTo,
"include_expired": req.IncludeExpired,
"portal_count": len(portals),
})
filter := tender.PendingDocumentScrapeFilter{
CountryCodes: documentScraperCountryCodes,
Portals: portals,
CreatedAtFrom: req.CreatedAtFrom,
CreatedAtTo: req.CreatedAtTo,
Limit: req.Limit,
@@ -106,12 +144,17 @@ func (s *service) ListPendingTenders(ctx context.Context, req DocumentScraperLis
}
// GetTenderByNoticeID retrieves a specific tender by its notice publication ID.
// Only returns tenders from Sweden, Finland, and Norway with active deadlines (deadline not yet reached).
// Only returns unexpired tenders whose document URL matches a supported scrape portal.
func (s *service) GetTenderByNoticeID(ctx context.Context, noticeID string) (*DocumentScraperTenderResponse, error) {
s.logger.Info("Getting tender by notice ID for document scraper", map[string]interface{}{
"notice_id": noticeID,
})
portals, err := s.fetchScrapePortals(ctx)
if err != nil {
return nil, err
}
t, err := s.tenderRepo.GetByNoticePublicationID(ctx, noticeID)
if err != nil {
s.logger.Error("Failed to get tender by notice ID", map[string]interface{}{
@@ -126,10 +169,9 @@ func (s *service) GetTenderByNoticeID(ctx context.Context, noticeID string) (*Do
}
now := time.Now().Unix()
if !isDocumentScraperCountry(t.CountryCode) || t.TenderDeadline <= now {
s.logger.Info("Tender filtered out: unsupported country or deadline passed", map[string]interface{}{
if t.TenderDeadline <= now || !matchesScrapePortals(documentURL(t), portals) {
s.logger.Info("Tender filtered out: unsupported portal or deadline passed", map[string]interface{}{
"notice_id": noticeID,
"country_code": t.CountryCode,
"tender_deadline": t.TenderDeadline,
"now": now,
})
+2 -11
View File
@@ -1,10 +1,6 @@
package document_scraper
import (
"strings"
"tm/internal/tender"
)
import "tm/internal/tender"
// BuildDocumentScraperTenderResponse maps a stored tender to the scraper API contract.
func BuildDocumentScraperTenderResponse(t *tender.Tender) *DocumentScraperTenderResponse {
@@ -12,15 +8,10 @@ func BuildDocumentScraperTenderResponse(t *tender.Tender) *DocumentScraperTender
return &DocumentScraperTenderResponse{}
}
documentURL := strings.TrimSpace(t.DocumentURI)
if documentURL == "" {
documentURL = strings.TrimSpace(t.TenderURL)
}
return &DocumentScraperTenderResponse{
ContractFolderID: t.ContractFolderID,
NoticePublicationID: t.NoticePublicationID,
DocumentURL: documentURL,
DocumentURL: documentURL(t),
Title: t.Title,
Description: t.Description,
}
+27 -5
View File
@@ -557,7 +557,7 @@ func (r *tenderRepository) GetExpiredTenders(ctx context.Context, beforeDate int
// PendingDocumentScrapeFilter defines query criteria for pending document scrape tenders.
type PendingDocumentScrapeFilter struct {
CountryCodes []string
Portals []string
MinDeadline *int64 // when set, tender_deadline must be greater than this value
CreatedAtFrom *int64
CreatedAtTo *int64
@@ -565,17 +565,39 @@ type PendingDocumentScrapeFilter struct {
Skip int
}
// GetPendingDocumentScrapeTenders returns unscraped tenders for the given countries.
func scrapePortalURLFilter(portals []string) bson.M {
conditions := make([]bson.M, 0, len(portals)*2)
for _, portal := range portals {
portal = strings.TrimSpace(portal)
if portal == "" {
continue
}
regex := bson.M{"$regex": regexp.QuoteMeta(portal), "$options": "i"}
conditions = append(conditions,
bson.M{"document_uri": regex},
bson.M{"tender_url": regex},
)
}
if len(conditions) == 0 {
return nil
}
return bson.M{"$or": conditions}
}
// GetPendingDocumentScrapeTenders returns unscraped tenders whose document URL matches a supported portal.
// When MinDeadline is set, only tenders with tender_deadline greater than that value are returned.
func (r *tenderRepository) GetPendingDocumentScrapeTenders(ctx context.Context, filter PendingDocumentScrapeFilter) ([]Tender, bool, error) {
if len(filter.CountryCodes) == 0 {
portalFilter := scrapePortalURLFilter(filter.Portals)
if portalFilter == nil {
return nil, false, nil
}
query := bson.M{
"country_code": bson.M{"$in": filter.CountryCodes},
"processing_metadata.documents_scraped": bson.M{"$ne": true},
}
for key, value := range portalFilter {
query[key] = value
}
if filter.MinDeadline != nil {
query["tender_deadline"] = bson.M{
@@ -606,7 +628,7 @@ func (r *tenderRepository) GetPendingDocumentScrapeTenders(ctx context.Context,
result, err := r.ormRepo.FindAll(ctx, query, pagination)
if err != nil {
r.logger.Error("Failed to get pending document scrape tenders", map[string]interface{}{
"country_codes": filter.CountryCodes,
"portals": filter.Portals,
"min_deadline": filter.MinDeadline,
"created_at_from": filter.CreatedAtFrom,
"created_at_to": filter.CreatedAtTo,