DEV Community

Cover image for How We Built a "No-Recalc" Tender Matching Engine with Actionable Feedback
mobius-crypt
mobius-crypt

Posted on

How We Built a "No-Recalc" Tender Matching Engine with Actionable Feedback

Government tender data in South Africa is notoriously messy. But the real challenge isn't just finding the data—it's matching it to businesses in a way that is accurate, performant, and, most importantly, explainable.

At Tenders SA, we moved away from opaque AI "black boxes" to build a transparent, feedback-driven matching engine - Which uses both AI and Matching Algos.

We wanted to answer two questions for our users: "Is this a good match?" and "If not, what can I do to fix it?"

Here is a deep dive into the architecture of our Tender Recommendation and Feedback Services, built with TypeScript and Prisma.

The Core Design Pattern: "Database as Cache"

One of the biggest performance killers in matching algorithms is calculating scores on the fly.

If you have 1,000 active tenders and 5,000 companies, running a complex scoring function ($1,000 \times 5,000$) every time a user loads their dashboard is unscalable.

We solved this with a strict architectural principle: The MatchingScore table is the cache.

Our TenderRecommendationService explicitly never recalculates scores on read. Instead, it queries a pre-computed MatchingScore table. This allows us to perform high-speed filtering and sorting using standard SQL queries via Prisma.

Here is how we fetch recommendations without killing the CPU:

// src/app/lib/services/tender-recommendation.service.ts

export class TenderRecommendationService {
    /**
     * READS from MatchingScore table - NEVER recalculates
     * Applies user preference filters on top of stored scores
     */
    async getRecommendationsForUser(
        userId: string,
        options: GetRecommendationsOptions = {}
    ): Promise<RecommendedTender[]> {
        // ... (setup code)

        // 1. Efficient Filtering directly in the DB query
        const whereClause: any = {
            companyId: company.id,
            score: { gte: minScore }, // Filter by pre-calculated score
            tender: {
                status: 'ACTIVE',
                closingDate: { gte: new Date() },
            },
        };

        // 2. Fetch matches with joined tender details
        const matchingScores = await db.matchingScore.findMany({
            where: whereClause,
            include: {
                tender: {
                    select: { id: true, title: true, status: true, /*...*/ },
                },
            },
            orderBy: { score: 'desc' }, // Instant sorting
            skip: offset,
            take: limit,
        });

        // 3. Map to DTOs (Data Transfer Objects)
        return matchingScores.map(ms => ({
            id: ms.id,
            score: ms.score,
            factors: parseFactors(ms.factors), // The "why" is stored as JSON
            matchCategory: getMatchCategory(ms.score),
            // ...
        }));
    }
}
Enter fullscreen mode Exit fullscreen mode

This approach allows us to serve thousands of concurrent requests with sub-millisecond latency, as the heavy lifting is done asynchronously by background workers that populate the MatchingScore table.

The Feedback Loop: Making Scores Actionable

A score of "45%" is useless if the user doesn't know why. We built a MatchingFeedbackService to decompose these scores into actionable advice.

We defined specific weights for different scoring factors:

// src/app/lib/services/matching-feedback.service.ts

const FACTOR_WEIGHTS: Record<string, number> = {
    industry_match: 0.15,
    bbbee_compliance: 0.15,
    geographic_match: 0.10,
    document_readiness: 0.10,
    technical_capability: 0.15,
    financial_capability: 0.15,
    // ...
};
Enter fullscreen mode Exit fullscreen mode

Using these weights, we generate dynamic Improvement Areas. If a user fails a specific check (like BBBEE compliance), we don't just lower their score—we tell them exactly how to fix it.

Here is the logic that generates those specific recommendations:

// src/app/lib/services/matching-feedback.service.ts

private static generateFactorRecommendation(
    factor: string,
    score: number,
    company: any
): { recommendation: string; actionable: boolean; } | null {
    switch (factor) {
        case 'bbbee_compliance':
            const currentLevel = company?.bbbeeLevel || 'Not specified';
            if (score < 50) {
                return {
                    recommendation: `Your BBBEE Level ${currentLevel} may not meet requirements. Consider upgrading to Level 1-4 to qualify for more tenders.`,
                    actionable: true
                };
            }
            return {
                recommendation: 'Maintain your BBBEE certification and consider improving to a higher level',
                actionable: true
            };

        case 'financial_capability':
            const turnover = company?.annualTurnover || 0;
            if (turnover < 1000000) {
                return {
                    recommendation: 'Update your annual turnover in your profile. Many tenders require minimum turnover thresholds.',
                    actionable: true
                };
            }
            // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Calculating the "Match Explanation"

We aggregate these insights into a MatchExplanation object. This creates a transparent "Report Card" for every tender.

The service analyzes the gaps and even estimates the "Time to Qualify":

// src/app/lib/services/tender-recommendation.service.ts

// Estimate time to qualify based on gaps
let estimatedTimeToQualify: string | null = null;
if (gaps.length === 0) {
    estimatedTimeToQualify = 'Ready to apply';
} else if (gaps.length <= 2) {
    estimatedTimeToQualify = '1-3 days (with document uploads)';
} else {
    estimatedTimeToQualify = '1-2 weeks (significant updates needed)';
}
Enter fullscreen mode Exit fullscreen mode

This is a game-changer for user experience. Instead of guessing if they can bid, users instantly know if they are "Ready to apply" or if they need "1-2 weeks" to prepare their compliance documents.

Categorization Logic

To make the UI scannable, we bucket matches into clear categories. We use a threshold-based approach:

  • Highly Qualified: Score ≥ 85
  • Good Match: Score ≥ 70
  • Potential: Score < 70

This simple logic powers our dashboard filters, allowing users to focus only on the opportunities they are statistically most likely to win.


Try Our Matching Engine

We are building in public and constantly refining our scoring weights and feedback algorithms.

  • See Our Launch Press Release: Launching Tenders SA - AI Base Tender Matching **Tenders SA Launch 2025

  • See it in action: Create a profile and see your personalized match scores at tenders-sa.org.

We'd love to hear your feedback on how we handle data modeling and scoring efficiency!

Top comments (0)