Building the AI Matching Engine for Personalized Tender Recommendations

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 agency
A construction contractor
A 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_matches table
  • 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
|
v
Matching Engine <------ Analyzed Tender
|
v
Relevance Score
|
+--> Match Reasons
+--> Missing Capabilities
+--> Recommendation Level
|
v
Tender 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:

cybersecurity
cloud migration
data analytics
software 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
|
v
Final 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 alignment
Technology alignment
Skill alignment
Keyword alignment
Contract-value alignment
Geographic alignment
Deadline suitability
Excluded-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 Azure
Business technology: Microsoft Azure

is a strong capability signal.

By contrast:

Tender country: Netherlands
Business 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_profiles
tenders
tender_summaries
tender_matches
saved_tenders
dismissed_tenders

The main output will be stored in:

tender_matches

A match record should contain information such as:

business_profile_id
tender_id
overall_score
industry_score
technology_score
skill_score
keyword_score
commercial_score
geography_score
deadline_score
recommendation_level
match_reasons
missing_capabilities
created_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 Azure
microsoft azure
AZURE

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 = 80
MEDIUM_MATCH_THRESHOLD = 60
LOW_MATCH_THRESHOLD = 40
MINIMUM_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, Field
class 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 re
import unicodedata
def 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 value
def 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 Computing
cloud computing
Cloud-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 Intelligence
AI
Machine 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:
healthcare
information technology
Business:
healthcare
public sector

Matching industry:

healthcare

Score:

1 matching industry
------------------- × 100 = 50
2 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:
Azure
Docker
Kubernetes
Business technologies:
Azure
Docker
PostgreSQL

Matches:

Azure
Docker

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 migration
Solution architecture
Security assessment
Business skills:
Cloud migration
Solution architecture
DevOps

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 migration
digital transformation
data platform
cybersecurity

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 value
Maximum 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,000
Maximum: €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:

Netherlands
Belgium
Germany
Remote
European Union

The tender may contain:

Country
Region
Delivery location
Remote-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 date
def 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 = 100
7 days remaining = 100
5 days remaining = 71.43
2 days remaining = 28.57
Expired = 0

Applying Exclusion Rules

Businesses may want to exclude certain opportunities.

Examples:

Military
Gambling
Tobacco
Construction
On-site only
Contract 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 match
Medium match
Low match
Not 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.00
Technology 66.67
Skills 66.67
Keywords 100.00
Contract value 100.00
Geography 100.00
Deadline 100.00

Weighted score:

82.34

Recommendation:

High match

Match reasons:

Relevant industry: public sector
Matching technologies: docker, microsoft azure
Matching skills: cloud migration, solution architecture
Contract value fits the preferred commercial range
Tender location matches the preferred delivery area
Submission deadline provides sufficient preparation time

Missing capabilities:

Technology: kubernetes
Skill: 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 select
from 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 changes
The tender analysis changes
Matching weights change
A 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:
continue
return 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
|
v
Tender Analyzed
|
v
Tender Matched Against Profiles
|
v
Recommendations 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 matches
MINUS
Dismissed 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_at
matching_version
profile_updated_at
analysis_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 with
your cloud migration and solution architecture capabilities.
The contract value is within your preferred range. You may
need 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 scores
Difficult testing
Higher API costs
Poor auditability
Prompt sensitivity
Slower 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 integration
Tender 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
|
v
Vector Similarity
|
+--> Structured Score
|
v
Hybrid Match Score

A possible future weighting model:

Structured capability score 55%
Semantic embedding similarity 30%
Commercial constraints 15%

The database already contains:

tender_embeddings
business_profile_embeddings

These tables will support that extension.


Creating an API Response Schema

Add to the API schemas:

from datetime import datetime
from pydantic import BaseModel
class 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 Depends
from sqlalchemy.orm import Session
from 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, timedelta
from 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 SimpleNamespace
from 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 >= 70
assert (
result.recommendation_level
in {"high", "medium"}
)
assert result.match_reasons
assert (
"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 loaded
Tender is loaded
Latest analysis is loaded
Matching engine is called
Tender match is created
Transaction 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 record
Second run:
Still 1 tender_match record
Updated 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 opened
Tender saved
Tender dismissed
Tender applied to
Tender marked irrelevant
Tender 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
|
v
Personalized 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 tenders
1,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
|
v
Candidate Filtering
|
v
Top 100–500 Candidates
|
v
Detailed 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 tender
2. Store raw source record
3. Normalize tender
4. Analyze tender with AI
5. Extract structured intelligence
6. Match tender against business profiles
7. Store match scores
8. 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
|
v
Ingestion Pipeline
|
v
Normalized Tender
|
v
AI Analysis Pipeline
|
v
Structured Tender Intelligence
|
v
AI Matching Engine
|
v
Personalized Tender Match
|
v
Recommendation 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 tenders
Understand tenders
Match tenders to businesses
Rank 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.

Discover more from BidRadar

Subscribe now to keep reading and get access to the full archive.

Continue reading