Introduction
The AI Tender Opportunity Scanner can now collect, normalize, store, and analyze tender opportunities.
The platform currently supports:
- Tender source connectors
- Raw tender record storage
- Duplicate detection
- Tender normalization
- AI-generated summaries
- Industry extraction
- Technology extraction
- Skill extraction
- Requirement extraction
The next challenge is personalization.
A tender may be valuable to one company but irrelevant to another.
For example, a tender for:
Microsoft Azure cloud migration services
may be highly relevant to a cloud consultancy.
The same tender may have little value for:
A graphic design agencyA construction contractorA bookkeeping company
The platform therefore needs more than tender analysis.
It needs a matching engine that can compare each tender with the capabilities, preferences, and commercial goals of a specific business.
In this article, we will build the AI Matching Engine for personalized tender recommendations.
The matching engine will:
- Load analyzed tender intelligence
- Load business profile information
- Compare industries, technologies, skills, and keywords
- Evaluate contract-value preferences
- Consider geographic and deadline constraints
- Calculate a normalized relevance score
- Generate a human-readable match explanation
- Store the result in the
tender_matchestable - Prevent unnecessary duplicate processing
This creates the core recommendation capability of the platform.
Development Progress
Project Scope and Roadmap ████████████████████ 100%MVP Definition ████████████████████ 100%Technology Stack ████████████████████ 100%System Architecture ████████████████████ 100%Database Design ████████████████████ 100%Backend Project Structure ████████████████████ 100%SQLAlchemy Models ████████████████████ 100%Alembic Migration ████████████████████ 100%Repository Layer ████████████████████ 100%CRUD Service Layer ████████████████████ 100%Tender Ingestion Pipeline ████████████████████ 100%AI Analysis Pipeline ████████████████████ 100%AI Matching Engine ████████████████████ 100%Frontend Dashboard ░░░░░░░░░░░░░░░░░░░░ 0%
What We Are Building
The matching workflow will look like this:
Business Profile | vMatching Engine <------ Analyzed Tender | vRelevance Score | +--> Match Reasons +--> Missing Capabilities +--> Recommendation Level | vTender Match Record
The matching engine sits between tender analysis and the user-facing recommendation feed.
It converts structured tender data into personalized opportunity rankings.
Why a Matching Engine Is Necessary
A traditional tender portal usually asks users to enter keywords.
For example:
cybersecuritycloud migrationdata analyticssoftware development
The platform then searches tender titles and descriptions for those exact words.
This produces several problems.
Exact keywords miss semantic relationships
A business may specialize in:
Identity and access management
while a tender asks for:
Privileged account security and authentication controls
The wording is different, but the opportunity may still be highly relevant.
Broad keywords create noisy results
Searching for:
software
may return hundreds of unrelated tenders.
Users have multiple selection criteria
A business may care about:
- Industry
- Technologies
- Skills
- Contract value
- Location
- Deadline
- Delivery model
- Certifications
- Excluded sectors
A single keyword score cannot represent all these factors.
The matching engine combines multiple signals into one explainable recommendation.
Matching Engine Architecture
The initial matching engine will use a hybrid scoring model.
Structured Matching +Keyword Matching +Commercial Constraints +Optional AI Reasoning | vFinal Match Score
The MVP will begin with deterministic scoring.
This is important because deterministic matching is:
- Easier to test
- Easier to explain
- Less expensive
- Faster to execute
- More predictable than relying entirely on an LLM
AI-generated explanations can be added after the score is calculated.
Later, semantic embeddings can improve the matching quality further.
Matching Signals
The initial engine will evaluate the following signals:
Industry alignmentTechnology alignmentSkill alignmentKeyword alignmentContract-value alignmentGeographic alignmentDeadline suitabilityExcluded-term penalties
Each signal contributes a percentage of the total match score.
Example weighting:
Industry alignment 20%Technology alignment 20%Skill alignment 25%Keyword alignment 15%Contract-value alignment 10%Geographic alignment 5%Deadline suitability 5% ----Total 100%
These values are not permanent.
They provide a reasonable starting point for the MVP.
Why Use Weighted Scoring?
Not every matching signal has equal importance.
A matching technology may be more valuable than a matching location.
For example:
Tender technology: Microsoft AzureBusiness technology: Microsoft Azure
is a strong capability signal.
By contrast:
Tender country: NetherlandsBusiness country: Netherlands
is useful, but it does not prove the business can deliver the project.
Weighted scoring allows us to represent these differences.
Existing Database Tables
The matching engine will use:
business_profilestenderstender_summariestender_matchessaved_tendersdismissed_tenders
The main output will be stored in:
tender_matches
A match record should contain information such as:
business_profile_idtender_idoverall_scoreindustry_scoretechnology_scoreskill_scorekeyword_scorecommercial_scoregeography_scoredeadline_scorerecommendation_levelmatch_reasonsmissing_capabilitiescreated_at
The exact fields depend on the SQLAlchemy model created earlier.
When your existing model uses fewer columns, the detailed score breakdown can initially be stored in a JSON field.
Matching Engine Folder Structure
Create the following structure:
app/│├── matching/│ ├── engine.py│ ├── normalizer.py│ ├── scoring.py│ ├── schemas.py│ ├── constants.py│ └── __init__.py│├── repositories/│ ├── tender_match_repository.py│ └── ...│├── services/│ ├── tender_matching_service.py│ └── ...
This separates low-level scoring logic from service orchestration.
Separating Matching Responsibilities
Each module has a specific responsibility.
normalizer.py
Normalizes text and list values.
Examples:
Microsoft Azuremicrosoft azureAZURE
should all become comparable.
scoring.py
Contains reusable scoring functions.
Examples:
- Set overlap
- Keyword overlap
- Range compatibility
- Geographic compatibility
engine.py
Combines individual scores into a final result.
schemas.py
Defines validated matching inputs and outputs.
constants.py
Contains weights and threshold values.
tender_matching_service.py
Coordinates repositories, matching, transactions, and persistence.
This division keeps the code maintainable.
Creating Matching Constants
Create:
app/matching/constants.py
Add:
MATCH_WEIGHTS = { "industry": 0.20, "technology": 0.20, "skill": 0.25, "keyword": 0.15, "contract_value": 0.10, "geography": 0.05, "deadline": 0.05,}HIGH_MATCH_THRESHOLD = 80MEDIUM_MATCH_THRESHOLD = 60LOW_MATCH_THRESHOLD = 40MINIMUM_RECOMMENDATION_SCORE = 40
The weights must add up to:
1.00
We can validate that during application startup or in tests.
Creating the Matching Schemas
Create:
app/matching/schemas.py
Add:
from pydantic import BaseModel, Fieldclass ScoreBreakdown(BaseModel): industry: float = Field(ge=0, le=100) technology: float = Field(ge=0, le=100) skill: float = Field(ge=0, le=100) keyword: float = Field(ge=0, le=100) contract_value: float = Field(ge=0, le=100) geography: float = Field(ge=0, le=100) deadline: float = Field(ge=0, le=100)class MatchResult(BaseModel): overall_score: float = Field(ge=0, le=100) recommendation_level: str score_breakdown: ScoreBreakdown match_reasons: list[str] missing_capabilities: list[str]
Pydantic ensures that no score falls outside the valid range.
Creating the Text Normalizer
Create:
app/matching/normalizer.py
Add:
import reimport unicodedatadef normalize_text(value: str | None) -> str: if not value: return "" value = unicodedata.normalize( "NFKD", value, ) value = value.lower().strip() value = re.sub( r"[^a-z0-9\s+#.-]", " ", value, ) value = re.sub( r"\s+", " ", value, ) return valuedef normalize_list( values: list[str] | None,) -> set[str]: if not values: return set() return { normalize_text(value) for value in values if normalize_text(value) }
Why Normalize Matching Data?
Consider these values:
Cloud Computingcloud computingCloud-Computing CLOUD COMPUTING
Without normalization, they may be treated as different entries.
Normalization reduces false mismatches.
Handling Synonyms
Simple normalization is not enough.
For example:
Artificial IntelligenceAIMachine Intelligence
may refer to similar capabilities.
Create a synonym map in:
app/matching/constants.py
TERM_ALIASES = { "ai": "artificial intelligence", "ml": "machine learning", "aws": "amazon web services", "gcp": "google cloud platform", "ms azure": "microsoft azure", "cyber security": "cybersecurity",}
Update the normalizer:
from app.matching.constants import ( TERM_ALIASES,)def normalize_term( value: str,) -> str: normalized = normalize_text(value) return TERM_ALIASES.get( normalized, normalized, )
Update list normalization:
def normalize_list( values: list[str] | None,) -> set[str]: if not values: return set() normalized_values = { normalize_term(value) for value in values if value } return { value for value in normalized_values if value }
This improves exact structured matching without requiring AI calls.
Creating the Set-Overlap Score
Create:
app/matching/scoring.py
Add:
def calculate_set_overlap_score( required: set[str], available: set[str],) -> float: if not required: return 50.0 if not available: return 0.0 matches = required.intersection( available ) return ( len(matches) / len(required) ) * 100
This function compares tender requirements with business capabilities.
Understanding the Neutral Score
When a tender has no information for a particular category, the function returns:
50
This represents a neutral result.
It avoids unfairly penalizing the business when tender data is missing.
Alternative strategies include:
- Removing the category from the weighted score
- Returning zero
- Imputing an average value
For the MVP, a neutral score keeps the implementation simple.
Later, we can dynamically redistribute weights when data is missing.
Calculating Industry Alignment
Suppose the tender analysis contains:
{ "industries": [ "Healthcare", "Information Technology" ]}
The business profile contains:
{ "target_industries": [ "Healthcare", "Public Sector" ]}
Normalized sets:
Tender:healthcareinformation technologyBusiness:healthcarepublic sector
Matching industry:
healthcare
Score:
1 matching industry------------------- × 100 = 502 tender industries
The industry score becomes:
50
Calculating Technology Alignment
Add:
def calculate_technology_score( tender_technologies: set[str], business_technologies: set[str],) -> float: return calculate_set_overlap_score( required=tender_technologies, available=business_technologies, )
Example:
Tender technologies:AzureDockerKubernetesBusiness technologies:AzureDockerPostgreSQL
Matches:
AzureDocker
Score:
2 / 3 × 100 = 66.67
Calculating Skill Alignment
Skills may be the strongest capability signal.
Add:
def calculate_skill_score( tender_skills: set[str], business_skills: set[str],) -> float: return calculate_set_overlap_score( required=tender_skills, available=business_skills, )
Example:
Tender skills:Cloud migrationSolution architectureSecurity assessmentBusiness skills:Cloud migrationSolution architectureDevOps
Score:
2 / 3 × 100 = 66.67
Identifying Missing Capabilities
The matching result should explain not only what matches, but also what is missing.
Add:
def find_missing_terms( required: set[str], available: set[str],) -> list[str]: return sorted( required.difference( available ) )
Example output:
[ "security assessment"]
This information can help the user decide whether to:
- Form a partnership
- Use a subcontractor
- Skip the tender
- Expand internal capabilities
Calculating Keyword Alignment
Business profiles may contain preferred keywords such as:
cloud migrationdigital transformationdata platformcybersecurity
The tender may contain these terms in its:
- Title
- Description
- Summary
- Requirements
- Technologies
- Skills
Create:
def calculate_keyword_score( tender_text: str, preferred_keywords: set[str],) -> float: if not preferred_keywords: return 50.0 normalized_text = tender_text.lower() matched_keywords = { keyword for keyword in preferred_keywords if keyword in normalized_text } return ( len(matched_keywords) / len(preferred_keywords) ) * 100
This is still exact phrase matching.
Embedding-based semantic matching will be added later.
Building the Searchable Tender Text
Create a combined text representation:
def build_tender_search_text( title: str, description: str | None, summary: str | None, industries: list[str] | None, technologies: list[str] | None, skills: list[str] | None, requirements: list[str] | None,) -> str: values = [ title, description or "", summary or "", " ".join(industries or []), " ".join(technologies or []), " ".join(skills or []), " ".join(requirements or []), ] return " ".join(values).lower()
This gives the keyword scorer a single searchable representation.
Calculating Contract-Value Alignment
A business profile may define:
Minimum preferred contract valueMaximum preferred contract value
A tender may contain:
Estimated contract value
Create:
def calculate_contract_value_score( tender_value: float | None, minimum_value: float | None, maximum_value: float | None,) -> float: if tender_value is None: return 50.0 if ( minimum_value is None and maximum_value is None ): return 100.0 if ( minimum_value is not None and tender_value < minimum_value ): difference = ( minimum_value - tender_value ) ratio = difference / minimum_value return max( 0.0, 100 - ratio * 100, ) if ( maximum_value is not None and tender_value > maximum_value ): difference = ( tender_value - maximum_value ) ratio = difference / maximum_value return max( 0.0, 100 - ratio * 100, ) return 100.0
This gives full points when the tender value falls inside the preferred range.
A tender slightly outside the range receives a partial score instead of an immediate zero.
Example Contract-Value Scores
Business preference:
Minimum: €50,000Maximum: €500,000
Tender A:
€200,000
Score:
100
Tender B:
€40,000
The tender is below the preferred minimum, but still close.
It receives a reduced score rather than zero.
Tender C:
€2,000,000
The tender may be too large for the business and receives a significantly lower score.
Calculating Geographic Alignment
A business may specify:
NetherlandsBelgiumGermanyRemoteEuropean Union
The tender may contain:
CountryRegionDelivery locationRemote-work status
Create:
def calculate_geography_score( tender_locations: set[str], preferred_locations: set[str],) -> float: if not preferred_locations: return 100.0 if not tender_locations: return 50.0 if tender_locations.intersection( preferred_locations ): return 100.0 if "remote" in tender_locations: return 80.0 return 0.0
Later, this can be improved with:
- Country-region relationships
- EU-wide procurement rules
- Travel-distance calculations
- Remote delivery support
- Cross-border eligibility
Calculating Deadline Suitability
A tender closing tomorrow may be technically relevant but operationally impossible.
Create:
from datetime import datedef calculate_deadline_score( submission_deadline: date | None, minimum_days_required: int = 7,) -> float: if submission_deadline is None: return 50.0 remaining_days = ( submission_deadline - date.today() ).days if remaining_days < 0: return 0.0 if remaining_days >= minimum_days_required: return 100.0 return ( remaining_days / minimum_days_required ) * 100
Example with a minimum preparation period of seven days:
14 days remaining = 1007 days remaining = 1005 days remaining = 71.432 days remaining = 28.57Expired = 0
Applying Exclusion Rules
Businesses may want to exclude certain opportunities.
Examples:
MilitaryGamblingTobaccoConstructionOn-site onlyContract value below €10,000
Create:
def find_excluded_terms( tender_text: str, excluded_keywords: set[str],) -> list[str]: normalized_text = tender_text.lower() return sorted( keyword for keyword in excluded_keywords if keyword in normalized_text )
If exclusions are found, we can apply a penalty.
def apply_exclusion_penalty( score: float, excluded_matches: list[str],) -> float: if not excluded_matches: return score penalty = min( 80, len(excluded_matches) * 25, ) return max( 0.0, score - penalty, )
A strict exclusion rule could also force the score to zero.
For the MVP, a configurable penalty provides more flexibility.
Creating the Recommendation Level
Add:
from app.matching.constants import ( HIGH_MATCH_THRESHOLD, MEDIUM_MATCH_THRESHOLD, LOW_MATCH_THRESHOLD,)def determine_recommendation_level( score: float,) -> str: if score >= HIGH_MATCH_THRESHOLD: return "high" if score >= MEDIUM_MATCH_THRESHOLD: return "medium" if score >= LOW_MATCH_THRESHOLD: return "low" return "not_recommended"
The levels are:
High matchMedium matchLow matchNot recommended
These categories make the dashboard easier to understand.
Creating Match Reasons
The system should explain why an opportunity was recommended.
Create:
def build_match_reasons( matched_industries: set[str], matched_technologies: set[str], matched_skills: set[str], contract_value_score: float, geography_score: float, deadline_score: float,) -> list[str]: reasons: list[str] = [] if matched_industries: reasons.append( "Relevant industries: " + ", ".join( sorted(matched_industries) ) ) if matched_technologies: reasons.append( "Matching technologies: " + ", ".join( sorted(matched_technologies) ) ) if matched_skills: reasons.append( "Matching skills: " + ", ".join( sorted(matched_skills) ) ) if contract_value_score >= 80: reasons.append( "Contract value fits the " "preferred commercial range." ) if geography_score >= 80: reasons.append( "Tender location matches " "the preferred delivery area." ) if deadline_score >= 80: reasons.append( "The submission deadline " "provides sufficient preparation time." ) return reasons
Explainability is essential.
A user should never see only:
Match score: 82
They should also see:
Why the score is 82
Building the Matching Engine
Create:
app/matching/engine.py
Imports:
from app.matching.constants import ( MATCH_WEIGHTS,)from app.matching.normalizer import ( normalize_list,)from app.matching.schemas import ( MatchResult, ScoreBreakdown,)from app.matching.scoring import ( apply_exclusion_penalty, build_match_reasons, calculate_contract_value_score, calculate_deadline_score, calculate_geography_score, calculate_keyword_score, calculate_set_overlap_score, determine_recommendation_level, find_excluded_terms, find_missing_terms,)
Creating the Engine Class
class TenderMatchingEngine: def match( self, tender, tender_analysis, business_profile, ) -> MatchResult: ...
The engine receives already-loaded objects.
It should not query the database directly.
This keeps it independent and easy to test.
Normalizing Tender Data
Inside the match() method:
tender_industries = normalize_list( tender_analysis.industries)tender_technologies = normalize_list( tender_analysis.technologies)tender_skills = normalize_list( tender_analysis.skills)
Normalize the business profile:
business_industries = normalize_list( business_profile.target_industries)business_technologies = normalize_list( business_profile.technologies)business_skills = normalize_list( business_profile.skills)
Normalize preference lists:
preferred_keywords = normalize_list( business_profile.preferred_keywords)excluded_keywords = normalize_list( business_profile.excluded_keywords)preferred_locations = normalize_list( business_profile.preferred_locations)
Tender location data:
tender_locations = normalize_list( [ tender.country, tender.region, tender.location, ])
Filter out empty values before normalization when necessary.
Calculating Individual Scores
industry_score = ( calculate_set_overlap_score( required=tender_industries, available=business_industries, ))technology_score = ( calculate_set_overlap_score( required=tender_technologies, available=business_technologies, ))skill_score = ( calculate_set_overlap_score( required=tender_skills, available=business_skills, ))
Create searchable tender text:
tender_text = " ".join( [ tender.title or "", tender.description or "", tender_analysis.summary or "", " ".join( tender_analysis.industries or [] ), " ".join( tender_analysis.technologies or [] ), " ".join( tender_analysis.skills or [] ), " ".join( tender_analysis.requirements or [] ), ]).lower()
Calculate the remaining scores:
keyword_score = calculate_keyword_score( tender_text=tender_text, preferred_keywords=preferred_keywords,)contract_value_score = ( calculate_contract_value_score( tender_value=tender.estimated_value, minimum_value=( business_profile .minimum_contract_value ), maximum_value=( business_profile .maximum_contract_value ), ))geography_score = ( calculate_geography_score( tender_locations=tender_locations, preferred_locations=preferred_locations, ))deadline_score = ( calculate_deadline_score( submission_deadline=( tender.submission_deadline ), minimum_days_required=( business_profile .minimum_preparation_days or 7 ), ))
Field names may need to be adjusted to match the exact SQLAlchemy models in your repository.
Calculating the Weighted Score
Add:
overall_score = ( industry_score * MATCH_WEIGHTS["industry"] + technology_score * MATCH_WEIGHTS["technology"] + skill_score * MATCH_WEIGHTS["skill"] + keyword_score * MATCH_WEIGHTS["keyword"] + contract_value_score * MATCH_WEIGHTS["contract_value"] + geography_score * MATCH_WEIGHTS["geography"] + deadline_score * MATCH_WEIGHTS["deadline"])
Round the result:
overall_score = round( overall_score, 2,)
Applying Exclusion Penalties
Find excluded terms:
excluded_matches = find_excluded_terms( tender_text=tender_text, excluded_keywords=excluded_keywords,)
Apply the penalty:
overall_score = apply_exclusion_penalty( score=overall_score, excluded_matches=excluded_matches,)
Round again:
overall_score = round( overall_score, 2,)
Determining Matches and Missing Capabilities
Find matched terms:
matched_industries = ( tender_industries .intersection( business_industries ))matched_technologies = ( tender_technologies .intersection( business_technologies ))matched_skills = ( tender_skills .intersection( business_skills ))
Find missing capabilities:
missing_technologies = find_missing_terms( required=tender_technologies, available=business_technologies,)missing_skills = find_missing_terms( required=tender_skills, available=business_skills,)
Combine them:
missing_capabilities = [ *[ f"Technology: {value}" for value in missing_technologies ], *[ f"Skill: {value}" for value in missing_skills ],]
Building the Explanation
match_reasons = build_match_reasons( matched_industries=matched_industries, matched_technologies=matched_technologies, matched_skills=matched_skills, contract_value_score=contract_value_score, geography_score=geography_score, deadline_score=deadline_score,)
Add exclusions:
if excluded_matches: match_reasons.append( "Score reduced because excluded " "terms were detected: " + ", ".join(excluded_matches) )
Determine the recommendation:
recommendation_level = ( determine_recommendation_level( overall_score ))
Returning the Match Result
return MatchResult( overall_score=overall_score, recommendation_level=( recommendation_level ), score_breakdown=ScoreBreakdown( industry=round( industry_score, 2, ), technology=round( technology_score, 2, ), skill=round( skill_score, 2, ), keyword=round( keyword_score, 2, ), contract_value=round( contract_value_score, 2, ), geography=round( geography_score, 2, ), deadline=round( deadline_score, 2, ), ), match_reasons=match_reasons, missing_capabilities=( missing_capabilities ),)
The matching engine is now a pure Python component.
It does not know about:
- FastAPI
- SQLAlchemy sessions
- HTTP responses
- Database commits
That makes it highly testable.
Example Match Calculation
Consider this business profile:
{ "target_industries": [ "Public Sector", "Healthcare" ], "technologies": [ "Microsoft Azure", "Docker", "PostgreSQL" ], "skills": [ "Cloud Migration", "DevOps", "Solution Architecture" ], "preferred_keywords": [ "digital transformation", "cloud migration" ], "minimum_contract_value": 50000, "maximum_contract_value": 500000, "preferred_locations": [ "Netherlands", "Remote" ]}
Tender analysis:
{ "industries": [ "Public Sector" ], "technologies": [ "Microsoft Azure", "Docker", "Kubernetes" ], "skills": [ "Cloud Migration", "Solution Architecture", "Security Assessment" ], "requirements": [ "Migrate existing workloads", "Implement containerized services", "Provide security documentation" ]}
Possible score breakdown:
Industry 100.00Technology 66.67Skills 66.67Keywords 100.00Contract value 100.00Geography 100.00Deadline 100.00
Weighted score:
82.34
Recommendation:
High match
Match reasons:
Relevant industry: public sectorMatching technologies: docker, microsoft azureMatching skills: cloud migration, solution architectureContract value fits the preferred commercial rangeTender location matches the preferred delivery areaSubmission deadline provides sufficient preparation time
Missing capabilities:
Technology: kubernetesSkill: security assessment
This is far more useful than a simple keyword result.
Creating the Tender Match Repository
Create:
app/repositories/tender_match_repository.py
Add:
from sqlalchemy import selectfrom app.models.tender_match import ( TenderMatch,)from app.repositories.base import ( BaseRepository,)class TenderMatchRepository( BaseRepository[TenderMatch]): def __init__(self, db): super().__init__( TenderMatch, db, ) def get_for_profile_and_tender( self, business_profile_id, tender_id, ): statement = select( TenderMatch ).where( TenderMatch.business_profile_id == business_profile_id, TenderMatch.tender_id == tender_id, ) return self.db.scalar( statement ) def get_for_profile( self, business_profile_id, minimum_score: float = 0, limit: int = 100, ): statement = ( select(TenderMatch) .where( TenderMatch.business_profile_id == business_profile_id, TenderMatch.overall_score >= minimum_score, ) .order_by( TenderMatch.overall_score .desc() ) .limit(limit) ) return list( self.db.scalars( statement ) )
Adding an Upsert Method
Tender matches may need recalculation.
For example:
The business profile changesThe tender analysis changesMatching weights changeA scoring bug is fixed
Add:
def upsert_match( self, business_profile_id, tender_id, values: dict,): existing = ( self.get_for_profile_and_tender( business_profile_id, tender_id, ) ) if existing: for key, value in values.items(): setattr( existing, key, value, ) return existing match = TenderMatch( business_profile_id=( business_profile_id ), tender_id=tender_id, **values, ) self.db.add(match) return match
An upsert avoids duplicate match records.
Creating the Matching Service
Create:
app/services/tender_matching_service.py
Imports:
from app.exceptions import ( ResourceNotFoundError,)from app.matching.engine import ( TenderMatchingEngine,)from app.repositories.business_profile_repository import ( BusinessProfileRepository,)from app.repositories.tender_match_repository import ( TenderMatchRepository,)from app.repositories.tender_repository import ( TenderRepository,)from app.repositories.tender_summary_repository import ( TenderSummaryRepository,)
Service Initialization
class TenderMatchingService: def __init__( self, db, matching_engine=None, ): self.db = db self.matching_engine = ( matching_engine or TenderMatchingEngine() ) self.profile_repository = ( BusinessProfileRepository(db) ) self.tender_repository = ( TenderRepository(db) ) self.summary_repository = ( TenderSummaryRepository(db) ) self.match_repository = ( TenderMatchRepository(db) )
Matching One Tender
Create:
def match_tender_to_profile( self, tender_id, business_profile_id,):
Load the business profile:
business_profile = ( self.profile_repository .get_by_id( business_profile_id ))if not business_profile: raise ResourceNotFoundError( "Business profile not found." )
Load the tender:
tender = ( self.tender_repository .get_by_id( tender_id ))if not tender: raise ResourceNotFoundError( "Tender not found." )
Load the latest analysis:
tender_analysis = ( self.summary_repository .get_latest_for_tender( tender_id ))if not tender_analysis: raise ResourceNotFoundError( "Tender analysis not found." )
Run the matching engine:
result = ( self.matching_engine.match( tender=tender, tender_analysis=tender_analysis, business_profile=business_profile, ))
Persisting the Match
Prepare the values:
values = { "overall_score": ( result.overall_score ), "recommendation_level": ( result.recommendation_level ), "score_breakdown": ( result.score_breakdown .model_dump() ), "match_reasons": ( result.match_reasons ), "missing_capabilities": ( result.missing_capabilities ),}
Upsert the match:
match = ( self.match_repository .upsert_match( business_profile_id=( business_profile_id ), tender_id=tender_id, values=values, ))
Commit:
self.db.commit()self.db.refresh(match)return match
Matching All Tenders for a Profile
Create:
def match_all_tenders_for_profile( self, business_profile_id,):
Load open tenders:
tenders = ( self.tender_repository .get_open_tenders())
Process each tender:
matches = []for tender in tenders: try: match = ( self.match_tender_to_profile( tender_id=tender.id, business_profile_id=( business_profile_id ), ) ) matches.append(match) except ResourceNotFoundError: continuereturn matches
This initial implementation commits once per tender through the single-match method.
For larger batches, we should optimize the transaction strategy.
Improving Batch Transaction Handling
A more efficient batch implementation:
def match_all_tenders_for_profile( self, business_profile_id,): business_profile = ( self.profile_repository .get_by_id( business_profile_id ) ) if not business_profile: raise ResourceNotFoundError( "Business profile not found." ) tenders = ( self.tender_repository .get_open_tenders() ) processed_matches = [] for tender in tenders: tender_analysis = ( self.summary_repository .get_latest_for_tender( tender.id ) ) if not tender_analysis: continue result = ( self.matching_engine.match( tender=tender, tender_analysis=tender_analysis, business_profile=( business_profile ), ) ) match = ( self.match_repository .upsert_match( business_profile_id=( business_profile_id ), tender_id=tender.id, values={ "overall_score": ( result.overall_score ), "recommendation_level": ( result .recommendation_level ), "score_breakdown": ( result.score_breakdown .model_dump() ), "match_reasons": ( result.match_reasons ), "missing_capabilities": ( result .missing_capabilities ), }, ) ) processed_matches.append( match ) self.db.commit() return processed_matches
This reduces database transaction overhead.
Matching a Tender Against All Profiles
When a new tender is ingested and analyzed, we may want to compare it with every active business profile.
Create:
def match_tender_to_all_profiles( self, tender_id,): profiles = ( self.profile_repository .get_active_profiles() ) matches = [] for profile in profiles: match = ( self.match_tender_to_profile( tender_id=tender_id, business_profile_id=( profile.id ), ) ) matches.append(match) return matches
This supports automatic recommendation generation after every ingestion run.
Connecting Matching to the Analysis Pipeline
The future processing workflow becomes:
Tender Ingested | vTender Analyzed | vTender Matched Against Profiles | vRecommendations Created
At the end of the AI analysis service, we could trigger:
matching_service.match_tender_to_all_profiles( tender_id=tender.id)
However, calling it directly creates tight coupling.
A better production design uses:
- Background jobs
- Domain events
- Message queues
- Task workers
For the MVP, direct service orchestration is acceptable.
We will introduce background processing later.
Filtering Recommendations
The platform should not display every calculated match.
Create a repository method:
def get_recommendations( self, business_profile_id, minimum_score: float = 40, limit: int = 50,): statement = ( select(TenderMatch) .where( TenderMatch.business_profile_id == business_profile_id, TenderMatch.overall_score >= minimum_score, ) .order_by( TenderMatch.overall_score .desc() ) .limit(limit) ) return list( self.db.scalars( statement ) )
This returns the strongest opportunities first.
Excluding Dismissed Tenders
A dismissed tender should normally disappear from the recommendation feed.
The recommendation query can exclude dismissed tenders using a subquery or join.
Conceptually:
Tender matchesMINUSDismissed tenders
Example repository logic:
from sqlalchemy import exists
statement = ( select(TenderMatch) .where( TenderMatch.business_profile_id == business_profile_id, TenderMatch.overall_score >= minimum_score, ~exists().where( DismissedTender.user_id == user_id, DismissedTender.tender_id == TenderMatch.tender_id, ), ) .order_by( TenderMatch.overall_score.desc() ))
This ensures the feed respects user actions.
Prioritizing Saved Tenders
Saved tenders do not necessarily need a higher match score.
Saving is a user action, not a model prediction.
Instead, saved tenders should be shown in a dedicated section or marked visually in the dashboard.
This preserves the meaning of the score.
Adding Match Freshness
A match can become stale when:
- The business profile changes
- The tender analysis changes
- Matching weights change
- The tender deadline changes
Add fields such as:
matched_atmatching_versionprofile_updated_atanalysis_version
The matching_version could contain:
v1
or:
structured-weighted-v1
This helps determine whether a match needs recalculation.
Creating a Matching Version Constant
Add to:
app/matching/constants.py
MATCHING_VERSION = ( "structured-weighted-v1")
Persist it with every match:
values = { "matching_version": ( MATCHING_VERSION ), ...}
When the algorithm changes:
MATCHING_VERSION = ( "structured-weighted-v2")
Existing records can then be identified as outdated.
Adding AI-Generated Explanations
The deterministic engine already produces clear explanations.
However, we may later use an LLM to turn the structured result into a more natural recommendation.
Input:
{ "score": 82.34, "matching_skills": [ "cloud migration", "solution architecture" ], "missing_skills": [ "security assessment" ], "contract_value_fit": true}
Possible AI output:
This opportunity is a strong fit because it aligns withyour cloud migration and solution architecture capabilities.The contract value is within your preferred range. You mayneed a security partner to satisfy the assessment requirement.
The LLM should explain the deterministic result.
It should not independently replace or override the score during the MVP.
Why Not Let the LLM Calculate the Entire Score?
An LLM-only matching system introduces several problems:
Inconsistent scoresDifficult testingHigher API costsPoor auditabilityPrompt sensitivitySlower processing
A deterministic score provides a stable foundation.
AI can later add:
- Semantic similarity
- Requirement interpretation
- Recommendation explanations
- Risk observations
This hybrid approach provides both reliability and intelligence.
Adding Semantic Matching Later
Exact structured matching will still miss some relationships.
For example:
Business skill:API integrationTender requirement:Connect the procurement platform with third-party systems
The terms are semantically related but do not match exactly.
Embedding-based matching will solve this.
Future architecture:
Business Profile Embedding | vVector Similarity | +--> Structured Score | vHybrid Match Score
A possible future weighting model:
Structured capability score 55%Semantic embedding similarity 30%Commercial constraints 15%
The database already contains:
tender_embeddingsbusiness_profile_embeddings
These tables will support that extension.
Creating an API Response Schema
Add to the API schemas:
from datetime import datetimefrom pydantic import BaseModelclass TenderMatchRead(BaseModel): id: int tender_id: int business_profile_id: int overall_score: float recommendation_level: str score_breakdown: dict match_reasons: list[str] missing_capabilities: list[str] created_at: datetime model_config = { "from_attributes": True }
This schema will later be used by the frontend dashboard.
Adding Matching Service Dependencies
Create or update:
app/api/dependencies/services.py
Add:
from fastapi import Dependsfrom sqlalchemy.orm import Sessionfrom app.database.session import ( get_db,)from app.services.tender_matching_service import ( TenderMatchingService,)def get_tender_matching_service( db: Session = Depends(get_db),): return TenderMatchingService( db=db )
This allows FastAPI routes to receive the matching service through dependency injection.
Optional Matching Endpoints
Although the full dashboard API will be built later, temporary endpoints can help us test the matching engine.
Create:
app/api/routes/matches.py
Example endpoint:
from fastapi import ( APIRouter, Depends,)from app.api.dependencies.services import ( get_tender_matching_service,)router = APIRouter( prefix="/matches", tags=["matches"],)
Match one tender:
router.post( "/profiles/{profile_id}/tenders/{tender_id}")def match_tender( profile_id: int, tender_id: int, service=Depends( get_tender_matching_service ),): return ( service.match_tender_to_profile( tender_id=tender_id, business_profile_id=profile_id, ) )
Match all tenders:
router.post( "/profiles/{profile_id}/run")def run_profile_matching( profile_id: int, service=Depends( get_tender_matching_service ),): return ( service .match_all_tenders_for_profile( business_profile_id=( profile_id ) ) )
Retrieve recommendations:
router.get( "/profiles/{profile_id}")def get_profile_matches( profile_id: int, minimum_score: float = 40, service=Depends( get_tender_matching_service ),): return service.get_recommendations( business_profile_id=profile_id, minimum_score=minimum_score, )
These endpoints are useful for development and testing.
In production, they must include ownership and authentication checks.
Testing the Scoring Functions
Create:
tests/matching/test_scoring.py
Test set overlap:
from app.matching.scoring import ( calculate_set_overlap_score,)def test_set_overlap_score(): required = { "azure", "docker", "kubernetes", } available = { "azure", "docker", "postgresql", } score = calculate_set_overlap_score( required=required, available=available, ) assert round(score, 2) == 66.67
Testing Missing Capabilities
from app.matching.scoring import ( find_missing_terms,)def test_missing_terms(): required = { "azure", "docker", "kubernetes", } available = { "azure", "docker", } result = find_missing_terms( required=required, available=available, ) assert result == [ "kubernetes" ]
Testing Contract-Value Alignment
from app.matching.scoring import ( calculate_contract_value_score,)def test_contract_value_inside_range(): score = ( calculate_contract_value_score( tender_value=200000, minimum_value=50000, maximum_value=500000, ) ) assert score == 100
Test a value below the range:
def test_contract_value_below_range(): score = ( calculate_contract_value_score( tender_value=40000, minimum_value=50000, maximum_value=500000, ) ) assert 0 < score < 100
Testing Deadline Suitability
from datetime import date, timedeltafrom app.matching.scoring import ( calculate_deadline_score,)def test_deadline_with_enough_time(): deadline = ( date.today() + timedelta(days=14) ) score = calculate_deadline_score( submission_deadline=deadline, minimum_days_required=7, ) assert score == 100
Test an expired tender:
def test_expired_deadline(): deadline = ( date.today() - timedelta(days=1) ) score = calculate_deadline_score( submission_deadline=deadline, ) assert score == 0
Testing the Complete Matching Engine
Create:
tests/matching/test_engine.py
Use simple fake objects:
from types import SimpleNamespacefrom app.matching.engine import ( TenderMatchingEngine,)
Create the tender:
tender = SimpleNamespace( title="Azure Cloud Migration", description=( "Migrate public-sector " "systems to Azure." ), estimated_value=200000, country="Netherlands", region=None, location="Remote", submission_deadline=deadline,)
Create the analysis:
analysis = SimpleNamespace( summary=( "Cloud migration project " "for a public organization." ), industries=[ "Public Sector" ], technologies=[ "Microsoft Azure", "Docker", "Kubernetes", ], skills=[ "Cloud Migration", "Solution Architecture", ], requirements=[ "Migration planning", "Container deployment", ],)
Create the business profile:
profile = SimpleNamespace( target_industries=[ "Public Sector" ], technologies=[ "Microsoft Azure", "Docker", ], skills=[ "Cloud Migration", "Solution Architecture", ], preferred_keywords=[ "cloud migration" ], excluded_keywords=[], minimum_contract_value=50000, maximum_contract_value=500000, preferred_locations=[ "Netherlands", "Remote", ], minimum_preparation_days=7,)
Run:
engine = TenderMatchingEngine()result = engine.match( tender=tender, tender_analysis=analysis, business_profile=profile,)
Verify:
assert result.overall_score >= 70assert ( result.recommendation_level in {"high", "medium"})assert result.match_reasonsassert ( "Technology: kubernetes" in result.missing_capabilities)
Testing the Matching Service
Create:
tests/services/test_tender_matching_service.py
The service test should verify:
Business profile is loadedTender is loadedLatest analysis is loadedMatching engine is calledTender match is createdTransaction is committed
Use either:
- A temporary test database
- Mock repositories
- Dependency injection with a fake engine
The engine itself should remain independently testable.
Testing Idempotency
Running the matcher twice should not create two records for the same profile and tender.
Test:
First run:1 tender_match recordSecond run:Still 1 tender_match recordUpdated score and timestamp
This confirms that the repository upsert works correctly.
Testing Weight Configuration
Create:
from app.matching.constants import ( MATCH_WEIGHTS,)def test_match_weights_equal_one(): total = sum( MATCH_WEIGHTS.values() ) assert round(total, 6) == 1.0
This prevents accidental scoring errors when the configuration changes.
Important Edge Cases
The matching engine must handle incomplete data.
No tender analysis
The tender should not be matched until analysis exists.
No business skills
The skill score should be zero or neutral, depending on the selected policy.
No tender technologies
The technology category should not unfairly penalize the result.
Unknown contract value
The engine should return a neutral commercial score.
Expired deadline
The tender should normally receive zero for deadline suitability and may be filtered out completely.
Empty business profile
The system should avoid generating misleading high scores.
Excluded sector
The score should receive a strong penalty or become zero.
Duplicate match
The existing record should be updated.
These cases should be included in automated tests.
Dynamic Weight Redistribution
The first implementation uses neutral scores when tender information is missing.
A more advanced implementation can redistribute weights dynamically.
Suppose the tender contains no location information.
Instead of assigning:
Geography score: 50
we can remove the geography category and distribute its 5% weight among the remaining categories.
Conceptually:
Original active weight total:95%Normalized industry weight:20 / 95
This may produce more mathematically accurate results.
However, it also makes the scoring implementation more complex.
For the MVP, neutral scoring is easier to explain and test.
Match Quality Monitoring
Once users interact with recommendations, the platform can collect feedback signals.
Examples:
Tender openedTender savedTender dismissedTender applied toTender marked irrelevantTender marked excellent
These signals can reveal whether the scoring model is effective.
For example:
High-score tenders frequently dismissed
may indicate poor weighting.
Medium-score tenders frequently saved
may indicate that the threshold is too conservative.
This feedback can later support machine-learning-based ranking.
Future Learning-to-Rank System
The deterministic matcher is the first version.
A future ranking model could learn from:
- Saved tenders
- Dismissed tenders
- Tender detail views
- Application activity
- User feedback
- Contract wins
- Company-specific behavior
The architecture could evolve into:
Structured Matching +Embedding Similarity +User Interaction Signals +Learning-to-Rank Model | vPersonalized Recommendation Score
We should not begin with this complexity.
The weighted deterministic engine is sufficient for validating the SaaS concept.
Security and Ownership Checks
Every matching operation must respect tenancy boundaries.
A user must not be able to:
- Match tenders against another user’s profile
- View another user’s recommendations
- Modify another user’s match records
- Access another organization’s capability data
The service layer should validate ownership before returning or calculating matches.
Conceptually:
profile = ( profile_repository .get_for_user( profile_id=profile_id, user_id=current_user.id, ))
If no profile is found:
Return not found or permission denied
These checks become essential once authentication is added.
Performance Considerations
Suppose the platform contains:
10,000 active tenders1,000 business profiles
Matching every tender against every profile creates:
10,000 × 1,000=10,000,000 comparisons
This is too expensive to execute synchronously after every change.
The MVP may operate with much smaller volumes, but the architecture should prepare for growth.
Potential optimizations include:
- Match only newly analyzed tenders
- Match only active profiles
- Pre-filter by industry
- Pre-filter by country
- Pre-filter by contract value
- Use vector similarity to identify candidate tenders
- Process matches in background jobs
- Recalculate only stale matches
- Batch database writes
Candidate Generation Before Scoring
A scalable matching system should not fully score every possible pair.
Instead:
All Tenders | vCandidate Filtering | vTop 100–500 Candidates | vDetailed Match Scoring
Candidate filtering can use:
- Shared industries
- Shared technologies
- Geographic compatibility
- Full-text search
- Embedding similarity
- Tender status
- Submission deadline
The MVP can initially score all active tenders for one profile.
Later, candidate generation will significantly improve performance.
Recommended Processing Flow
The complete operational workflow is now:
1. Ingest tender2. Store raw source record3. Normalize tender4. Analyze tender with AI5. Extract structured intelligence6. Match tender against business profiles7. Store match scores8. Display personalized recommendations
The first seven steps are now represented in the backend architecture.
The next major step is exposing these results through the frontend.
Current Architecture
The backend now supports:
External Tender Source | vIngestion Pipeline | vNormalized Tender | vAI Analysis Pipeline | vStructured Tender Intelligence | vAI Matching Engine | vPersonalized Tender Match | vRecommendation Feed
This is the core business workflow of the AI Tender Opportunity Scanner.
Current Deliverables
At this stage, we have implemented the design for:
✔ Matching constants and thresholds✔ Text and list normalization✔ Synonym handling✔ Industry matching✔ Technology matching✔ Skill matching✔ Keyword matching✔ Contract-value scoring✔ Geographic scoring✔ Deadline scoring✔ Exclusion penalties✔ Weighted relevance calculation✔ Recommendation levels✔ Match explanations✔ Missing-capability detection✔ Tender match persistence✔ Match upsert behavior✔ Batch matching✔ Idempotency strategy✔ Unit-testing strategy✔ Integration-testing strategy
The platform can now determine which procurement opportunities are most relevant to each business.
Recommended Git Commit
git add .git commit -m "Implement personalized tender matching engine"
What We Will Build Next
The backend can now:
Collect tendersUnderstand tendersMatch tenders to businessesRank personalized opportunities
The next step is making those opportunities visible to users.
We will build the frontend dashboard where users can:
- View recommended tenders
- See match scores
- Filter opportunities
- Inspect match explanations
- Review missing capabilities
- Save interesting tenders
- Dismiss irrelevant tenders
- Open detailed tender information
The next article in the development series is:
“Building the React Dashboard for Personalized Tender Recommendations.”
Conclusion
The AI Matching Engine is the central recommendation component of the AI Tender Opportunity Scanner.
It compares analyzed tender intelligence with each company’s capabilities, industries, technologies, preferences, commercial limits, and geographic constraints.
Rather than relying on a single keyword search, the engine combines multiple matching signals into a normalized and explainable relevance score.
The first version deliberately uses deterministic weighted scoring because it is fast, testable, affordable, and auditable. AI-generated explanations and embedding-based semantic similarity can later improve the experience without replacing this reliable foundation.
With ingestion, AI analysis, and personalized matching now connected, the backend can produce a ranked opportunity feed for every business profile.
The next stage is to transform those backend results into a practical user experience through the React recommendation dashboard.