How we built 10+ specialized tools for government tender preparation including CIDB grading, B-BBEE calculators, JV analysis, and AI proposal generation
Building an AI-Powered Tender Tools Suite: From Calculators to AI Proposal Generation
After building our professional services platform, we realized businesses needed more than just compliance helpβthey needed strategic tools to make better bidding decisions.
This is the technical story of how we built a comprehensive toolkit including:
- π CIDB Grade Calculator
- π― B-BBEE Points Estimator
- π Tender Preparation Planner
- π€ Joint Venture Suite with fronting risk detection
- π€ AI Proposal Generator
- π Tender Value Estimator with historical data analysis
- πΊοΈ Provincial Tender Heatmap
Let's dive into the architecture, algorithms, and lessons learned.
The Philosophy: Tools, Not Black Boxes
We wanted users to understand their calculations, not just trust them. This meant:
interface ToolOutput<T> {
result: T;
confidence: number;
reasoning: string[];
sources?: string[];
suggestions?: string[];
}
Every tool returns not just a number, but why that number and how to improve it.
Architecture: Shared Tool Infrastructure
Rather than building each tool from scratch, we created a shared framework:
// lib/tools/base-tool.ts
export abstract class BaseTool<TInput, TOutput> {
abstract name: string;
abstract description: string;
// Validation
abstract validateInput(input: TInput): ValidationResult;
// Calculation
abstract calculate(input: TInput): Promise<TOutput>;
// Explanation
abstract explain(input: TInput, output: TOutput): string[];
// Optional: Save to profile
async saveToProfile?(userId: string, result: TOutput): Promise<void>;
}
This gave us:
- β Consistent input validation
- β Built-in error handling
- β Automatic explanation generation
- β Optional profile integration
Tool #1: CIDB Grade Calculator
The Construction Industry Development Board (CIDB) uses financial and project history to determine contractor grades (1-9). Higher grades = bigger contract values.
The Algorithm
// lib/tools/cidb-calculator.ts
interface CidbInput {
classOfWorks: CidbClass; // e.g., "GB" (General Building)
bestTurnover: number;
availableCapital: number;
largestContract: number;
}
interface CidbOutput {
grade: number;
maxContractValue: number;
nextGrade?: {
grade: number;
shortfall: {
turnover: number;
capital: number;
contract: number;
};
};
reason: string;
}
export function calculateCidbGrade(input: CidbInput): CidbOutput {
const { bestTurnover, availableCapital, largestContract } = input;
// CIDB uses the LOWEST qualifying metric
const gradeByTurnover = getGradeFromTurnover(bestTurnover);
const gradeByCapital = getGradeFromCapital(availableCapital);
const gradeByContract = getGradeFromContract(largestContract);
const grade = Math.min(gradeByTurnover, gradeByCapital, gradeByContract);
// Calculate shortfall for next grade
const nextGrade = grade < 9 ? grade + 1 : undefined;
const nextGradeData = nextGrade ? {
grade: nextGrade,
shortfall: {
turnover: Math.max(0, TURNOVER_THRESHOLDS[nextGrade] - bestTurnover),
capital: Math.max(0, CAPITAL_THRESHOLDS[nextGrade] - availableCapital),
contract: Math.max(0, CONTRACT_THRESHOLDS[nextGrade] - largestContract),
},
} : undefined;
return {
grade,
maxContractValue: MAX_CONTRACT_VALUES[grade],
nextGrade: nextGradeData,
reason: explainGrade(grade, gradeByTurnover, gradeByCapital, gradeByContract),
};
}
The key insight: identify the limiting factor. If turnover qualifies for Grade 7 but capital only qualifies for Grade 5, the contractor gets Grade 5.
Thresholds as Lookup Tables
// CIDB Practice Note 21 thresholds (simplified)
const TURNOVER_THRESHOLDS: Record<number, number> = {
1: 200_000,
2: 500_000,
3: 2_000_000,
4: 4_000_000,
5: 6_500_000,
6: 13_000_000,
7: 40_000_000,
8: 130_000_000,
9: Infinity,
};
const MAX_CONTRACT_VALUES: Record<number, number> = {
1: 200_000,
2: 650_000,
3: 2_000_000,
4: 4_000_000,
5: 6_500_000,
6: 13_000_000,
7: 40_000_000,
8: 130_000_000,
9: Infinity,
};
The UI Component
We built an interactive form with instant feedback:
// components/tools/cidb/GradeForm.tsx
'use client';
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
export function GradeForm({ onCalculate }: GradeFormProps) {
const form = useForm<CidbInput>({
resolver: zodResolver(cidbSchema),
defaultValues: {
classOfWorks: "",
bestTurnover: 0,
availableCapital: 0,
largestContract: 0,
},
});
function onSubmit(values: CidbInput) {
const result = calculateCidbGrade(values);
onCalculate(result);
}
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<Select name="classOfWorks">
<SelectItem value="GB">General Building</SelectItem>
<SelectItem value="CE">Civil Engineering</SelectItem>
{/* ... */}
</Select>
<Input
type="number"
placeholder="Best Annual Turnover (R)"
{...form.register("bestTurnover", { valueAsNumber: true })}
/>
<Button type="submit">Calculate Grade</Button>
</form>
);
}
Result Display with Upgrade Path
The magic is in showing users exactly what they need to reach the next grade:
// components/tools/cidb/GradeResult.tsx
export function GradeResult({ result }: { result: CidbOutput }) {
return (
<div>
<div className="text-center">
<Badge>Grade {result.grade}</Badge>
<p>Max Contract Value: {formatCurrency(result.maxContractValue)}</p>
</div>
{result.nextGrade && (
<div className="border-t mt-6 pt-6">
<h3>Path to Grade {result.nextGrade.grade}</h3>
{result.nextGrade.shortfall.turnover > 0 && (
<div className="flex justify-between">
<span>Additional Turnover Needed:</span>
<span className="font-bold">
{formatCurrency(result.nextGrade.shortfall.turnover)}
</span>
</div>
)}
{/* Show capital and contract shortfalls similarly */}
</div>
)}
</div>
);
}
Tool #2: B-BBEE Points Calculator
B-BBEE (Broad-Based Black Economic Empowerment) is complex. Companies score points across 5 pillars:
interface BbbeeInput {
// Ownership (25 points)
blackOwnership: number;
blackWomenOwnership: number;
// Management (19 points)
blackExecutives: number;
blackSeniorManagers: number;
// Skills Development (20 points + 5 bonus)
skillsSpend: number;
targetSkillsSpend: number;
// Enterprise Development (40 points + 4 bonus)
procurementSpend: number;
bbbeeCompliantSpend: number;
// Socio-Economic Development (5 points)
sedContributions: number;
}
interface BbbeeOutput {
totalScore: number;
level: number; // 1-8
procurementRecognition: number; // e.g., 135% for Level 1
breakdown: {
ownership: number;
management: number;
skills: number;
enterprise: number;
sed: number;
};
gaps: {
element: string;
current: number;
target: number;
impact: string;
}[];
}
The Calculation Engine
export function calculateBbbee(input: BbbeeInput): BbbeeOutput {
const scores = {
ownership: calculateOwnershipScore(input),
management: calculateManagementScore(input),
skills: calculateSkillsScore(input),
enterprise: calculateEnterpriseScore(input),
sed: calculateSedScore(input),
};
const totalScore = Object.values(scores).reduce((sum, s) => sum + s, 0);
// Determine level (requires 40% on priority elements)
const level = determineBbbeeLevel(totalScore, scores);
// Calculate procurement recognition
const procurementRecognition = RECOGNITION_LEVELS[level];
// Identify improvement opportunities
const gaps = identifyGaps(input, scores);
return {
totalScore,
level,
procurementRecognition,
breakdown: scores,
gaps,
};
}
Gap Analysis: The Secret Sauce
function identifyGaps(
input: BbbeeInput,
scores: BbbeeScores
): Gap[] {
const gaps: Gap[] = [];
// Check each element
if (scores.ownership < OWNERSHIP_TARGET) {
const shortfall = OWNERSHIP_TARGET - scores.ownership;
gaps.push({
element: "Ownership",
current: scores.ownership,
target: OWNERSHIP_TARGET,
impact: `${shortfall} additional points could improve your level`,
});
}
// Priority elements must hit 40% threshold
const priorityElements = ['ownership', 'skills', 'enterprise'];
for (const element of priorityElements) {
const score = scores[element];
const maxScore = MAX_SCORES[element];
const threshold = maxScore * 0.4;
if (score < threshold) {
gaps.push({
element: capitalize(element),
current: score,
target: threshold,
impact: "CRITICAL: Priority element not meeting 40% threshold",
});
}
}
return gaps.sort((a, b) =>
(b.target - b.current) - (a.target - a.current)
);
}
Users don't just see their levelβthey see exactly where to invest for maximum B-BBEE improvement.
Tool #3: Joint Venture Calculator Suite
This was our most complex tool. Joint Ventures (JVs) allow companies to combine for larger tenders, but the B-BBEE and CIDB calculations are intricate.
The Data Model
// lib/store/useJvStore.ts
interface Partner {
id: string;
name: string;
type: 'Lead' | 'Partner';
// Equity
equityShare: number; // % ownership
// B-BBEE
bbbeeLevel: number;
bbbeeScore: number;
// CIDB
cidbGrade?: number;
cidbClass?: string;
// Work allocation
workShare: number; // % of actual work
profitShare: number; // % of profit
}
interface JvAnalysis {
bbbeeResult: {
consolidatedLevel: number;
consolidatedScore: number;
frontingRisk: 'LOW' | 'MEDIUM' | 'HIGH';
riskReason?: string;
};
cidbResult: {
grade: number;
reason: string;
};
}
Consolidated B-BBEE Calculation
export function calculateConsolidatedBbbee(
partners: Partner[]
): BbbeeResult {
// Weighted average by equity share
const totalScore = partners.reduce((sum, p) => {
return sum + (p.bbbeeScore * (p.equityShare / 100));
}, 0);
const consolidatedLevel = scoreToLevel(totalScore);
// Fronting risk detection
const frontingRisk = detectFrontingRisk(partners);
return {
consolidatedLevel,
consolidatedScore: totalScore,
frontingRisk: frontingRisk.level,
riskReason: frontingRisk.reason,
};
}
function detectFrontingRisk(partners: Partner[]): FrontingRisk {
// Red flag: High equity but low work share
for (const partner of partners) {
const equityToWorkRatio = partner.equityShare / partner.workShare;
if (equityToWorkRatio > 1.5) {
return {
level: 'HIGH',
reason: `${partner.name} has ${partner.equityShare}% equity but only ${partner.workShare}% work allocation. This may trigger fronting scrutiny.`,
};
}
}
// Warning: Profit share doesn't match work share
for (const partner of partners) {
const workToProfitRatio = partner.workShare / partner.profitShare;
if (Math.abs(workToProfitRatio - 1) > 0.3) {
return {
level: 'MEDIUM',
reason: `Work share (${partner.workShare}%) and profit share (${partner.profitShare}%) variance detected for ${partner.name}.`,
};
}
}
return { level: 'LOW' };
}
CIDB JV Grade Upgrade Logic
CIDB allows JVs to tender one grade higher:
export function calculateJvCidbGrade(partners: Partner[]): CidbResult {
const leadPartner = partners.find(p => p.type === 'Lead');
const otherPartners = partners.filter(p => p.type !== 'Lead');
if (!leadPartner?.cidbGrade) {
return { grade: 0, reason: "Lead partner CIDB grade required" };
}
// Standard upgrade: Two Grade N = Grade N+1
const allSameGrade = otherPartners.every(
p => p.cidbGrade === leadPartner.cidbGrade
);
if (allSameGrade && partners.length >= 2) {
return {
grade: leadPartner.cidbGrade + 1,
reason: `Upgraded from Grade ${leadPartner.cidbGrade} (two or more partners)`,
};
}
// Alternative: Lead at N, two partners at N-1
const partnersOneBelowLead = otherPartners.filter(
p => p.cidbGrade === leadPartner.cidbGrade - 1
);
if (partnersOneBelowLead.length >= 2) {
return {
grade: leadPartner.cidbGrade + 1,
reason: `Upgraded from Grade ${leadPartner.cidbGrade} (lead at N, two partners at N-1)`,
};
}
// No upgrade
return {
grade: leadPartner.cidbGrade,
reason: "No upgrade criteria met",
};
}
State Management with Zustand
We used Zustand for client-side JV state:
// lib/store/useJvStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface JvStore {
partners: Partner[];
bbbeeResult: BbbeeResult | null;
cidbResult: CidbResult | null;
// Actions
addPartner: (partner: Partner) => void;
updatePartner: (id: string, updates: Partial<Partner>) => void;
removePartner: (id: string) => void;
runAnalysis: () => void;
}
export const useJvStore = create<JvStore>()(
persist(
(set, get) => ({
partners: [],
bbbeeResult: null,
cidbResult: null,
addPartner: (partner) =>
set((state) => ({ partners: [...state.partners, partner] })),
updatePartner: (id, updates) =>
set((state) => ({
partners: state.partners.map(p =>
p.id === id ? { ...p, ...updates } : p
),
})),
removePartner: (id) =>
set((state) => ({
partners: state.partners.filter(p => p.id !== id),
})),
runAnalysis: () => {
const { partners } = get();
if (partners.length === 0) return;
const bbbeeResult = calculateConsolidatedBbbee(partners);
const cidbResult = calculateJvCidbGrade(partners);
set({ bbbeeResult, cidbResult });
},
}),
{ name: 'jv-store' }
)
);
This persists JV data across sessions, so users can build complex JV structures over time.
Tool #4: AI Proposal Generator
This was our first foray into generative AI for tender documents.
The Architecture
// lib/ai/proposal-generator.ts
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
interface ProposalRequest {
tenderTitle: string;
tenderDescription: string;
companyName: string;
companyProfile: string;
documentType: 'cover-letter' | 'executive-summary' | 'capability-statement';
}
export async function generateProposal(
request: ProposalRequest
): Promise<string> {
const systemPrompt = getSystemPrompt(request.documentType);
const userPrompt = buildUserPrompt(request);
const message = await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 2000,
system: systemPrompt,
messages: [{
role: 'user',
content: userPrompt,
}],
});
return message.content[0].text;
}
The Prompts: Domain-Specific Instructions
function getSystemPrompt(documentType: string): string {
const prompts = {
'cover-letter': `You are an expert tender response writer for South African government procurement.
Generate a professional cover letter that:
- Addresses the tender requirements explicitly
- Demonstrates understanding of government procurement regulations (PPPFA, PFMA)
- Highlights B-BBEE compliance
- Uses formal business language
- Includes standard SBD references
- Is exactly 1 page (300-400 words)
Do NOT use placeholder text. Use the company information provided.`,
'executive-summary': `You are creating an executive summary for a tender response.
The summary must:
- Open with a strong value proposition
- Outline the proposed solution/approach
- Highlight company strengths and differentiators
- Reference relevant experience
- Be 2-3 pages (600-800 words)
- Use bullet points for key advantages
- Maintain professional tone
Focus on BENEFITS, not just features.`,
'capability-statement': `You are writing a capability statement for a tender.
Include:
- Company overview and history
- Core competencies
- Relevant project experience (3-5 case studies)
- Team qualifications
- Quality assurance processes
- Health and safety commitments
- B-BBEE status and transformation credentials
Length: 3-4 pages (900-1200 words)`,
};
return prompts[documentType];
}
Streaming for Better UX
We implemented streaming to show content as it's generated:
// components/tools/template-generator/TemplateTool.tsx
'use client';
import { useState } from 'react';
export function TemplateTool() {
const [content, setContent] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
async function handleGenerate(data: ProposalRequest) {
setIsGenerating(true);
setContent('');
const response = await fetch('/api/tools/generate-proposal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader!.read();
if (done) break;
const chunk = decoder.decode(value);
setContent(prev => prev + chunk);
}
setIsGenerating(false);
}
return (
<div>
<ProposalForm onSubmit={handleGenerate} />
{isGenerating && (
<div className="flex items-center gap-2">
<Loader2 className="animate-spin" />
<span>Generating...</span>
</div>
)}
{content && (
<div className="prose max-w-none">
<ReactMarkdown>{content}</ReactMarkdown>
<Button onClick={() => copyToClipboard(content)}>
Copy to Clipboard
</Button>
</div>
)}
</div>
);
}
Cost Optimization
AI calls can get expensive. We implemented caching:
import { createHash } from 'crypto';
import { redis } from '@/lib/redis';
async function generateProposal(
request: ProposalRequest
): Promise<string> {
// Generate cache key from request
const cacheKey = `proposal:${hashRequest(request)}`;
// Check cache
const cached = await redis.get(cacheKey);
if (cached) {
return cached;
}
// Generate new
const result = await callAnthropicAPI(request);
// Cache for 7 days
await redis.setex(cacheKey, 604800, result);
return result;
}
function hashRequest(request: ProposalRequest): string {
const content = JSON.stringify(request);
return createHash('sha256').update(content).digest('hex');
}
This reduced our AI costs by ~60% for similar tender requests.
Tool #5: Tender Value Estimator
This tool uses historical award data to predict tender values.
The Data Pipeline
We scrape OCDS (Open Contracting Data Standard) feeds:
// scripts/import-ocds-data.ts
interface OcdsRelease {
id: string;
tender: {
title: string;
description: string;
value: {
amount: number;
currency: string;
};
};
awards?: Array<{
value: {
amount: number;
currency: string;
};
date: string;
}>;
}
async function importOcdsData() {
const releases = await fetchOcdsReleases();
for (const release of releases) {
// Extract and normalize
const award = release.awards?.[0];
if (!award) continue;
await db.historicalAward.create({
data: {
ocdsId: release.id,
title: release.tender.title,
description: release.tender.description,
estimatedValue: release.tender.value.amount,
awardValue: award.value.amount,
currency: award.value.currency,
awardDate: new Date(award.date),
category: classifyTender(release.tender.title),
province: extractProvince(release.tender.title),
},
});
}
}
The Estimation Algorithm
// lib/tools/value-estimator.ts
interface EstimationInput {
title: string;
description: string;
category: string;
province: string;
}
interface EstimationOutput {
estimatedMin: number;
estimatedMax: number;
estimatedMedian: number;
confidenceScore: number;
sampleSize: number;
comparables: HistoricalAward[];
}
export async function estimateTenderValue(
input: EstimationInput
): Promise<EstimationOutput> {
// Find similar tenders
const comparables = await findComparables(input);
if (comparables.length < 3) {
return {
estimatedMin: 0,
estimatedMax: 0,
estimatedMedian: 0,
confidenceScore: 0,
sampleSize: 0,
comparables: [],
};
}
// Calculate statistics
const values = comparables.map(c => c.awardValue).sort((a, b) => a - b);
const estimatedMin = percentile(values, 25);
const estimatedMedian = percentile(values, 50);
const estimatedMax = percentile(values, 75);
// Confidence based on sample size and variance
const confidenceScore = calculateConfidence(values);
return {
estimatedMin,
estimatedMax,
estimatedMedian,
confidenceScore,
sampleSize: values.length,
comparables: comparables.slice(0, 10), // Top 10 most similar
};
}
Similarity Matching with Embeddings
We use OpenAI embeddings for semantic similarity:
import OpenAI from 'openai';
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
async function findComparables(
input: EstimationInput
): Promise<HistoricalAward[]> {
// Generate embedding for input
const inputText = `${input.title} ${input.description}`;
const inputEmbedding = await generateEmbedding(inputText);
// Find similar awards using vector similarity
const results = await db.$queryRaw`
SELECT *,
1 - (embedding <=> ${inputEmbedding}::vector) AS similarity
FROM historical_awards
WHERE category = ${input.category}
AND province = ${input.province}
AND 1 - (embedding <=> ${inputEmbedding}::vector) > 0.7
ORDER BY similarity DESC
LIMIT 50
`;
return results;
}
async function generateEmbedding(text: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: text,
});
return response.data[0].embedding;
}
This gives much better results than keyword matching alone.
Tool #6: Provincial Tender Heatmap
Interactive data visualization using Recharts:
// components/analytics/heatmap-dashboard.tsx
'use client';
import { useState, useEffect } from 'react';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
interface ProvinceData {
province: string;
tenderCount: number;
totalValue: number;
}
export function HeatmapDashboard() {
const [data, setData] = useState<ProvinceData[]>([]);
const [timeRange, setTimeRange] = useState<'7d' | '30d' | '90d'>('30d');
useEffect(() => {
loadHeatmapData(timeRange).then(setData);
}, [timeRange]);
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1>Provincial Tender Heatmap</h1>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectItem value="7d">Last 7 Days</SelectItem>
<SelectItem value="30d">Last 30 Days</SelectItem>
<SelectItem value="90d">Last 90 Days</SelectItem>
</Select>
</div>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={data}>
<XAxis dataKey="province" />
<YAxis />
<Tooltip />
<Bar
dataKey="tenderCount"
fill="hsl(var(--primary))"
radius={[8, 8, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
<ProvinceGrid data={data} />
</div>
);
}
Data Aggregation
// lib/analytics/heatmap.ts
export async function loadHeatmapData(
timeRange: string
): Promise<ProvinceData[]> {
const days = timeRange === '7d' ? 7 : timeRange === '30d' ? 30 : 90;
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
const data = await db.tender.groupBy({
by: ['province'],
where: {
publishedAt: { gte: startDate },
status: 'ACTIVE',
},
_count: { id: true },
_sum: { estimatedValue: true },
});
return data.map(d => ({
province: d.province,
tenderCount: d._count.id,
totalValue: d._sum.estimatedValue || 0,
}));
}
Performance Optimizations
1. Memoization for Expensive Calculations
import { useMemo } from 'react';
function BbbeeCalculator({ input }: { input: BbbeeInput }) {
const result = useMemo(() => {
return calculateBbbee(input);
}, [input]);
return <BbbeeResult result={result} />;
}
2. Web Workers for Heavy Computations
// lib/workers/calculations.worker.ts
self.addEventListener('message', (e) => {
const { type, data } = e.data;
switch (type) {
case 'CALCULATE_CIDB':
const result = calculateCidbGrade(data);
self.postMessage({ type: 'CIDB_RESULT', result });
break;
case 'CALCULATE_BBBEE':
const bbbeeResult = calculateBbbee(data);
self.postMessage({ type: 'BBBEE_RESULT', result: bbbeeResult });
break;
}
});
Usage:
const worker = new Worker('/workers/calculations.worker.js');
worker.postMessage({ type: 'CALCULATE_CIDB', data: input });
worker.addEventListener('message', (e) => {
if (e.data.type === 'CIDB_RESULT') {
setResult(e.data.result);
}
});
3. Database Indexes
model HistoricalAward {
id String @id @default(cuid())
category String
province String
awardValue Float
awardDate DateTime
embedding Unsupported("vector(1536)")?
@@index([category, province])
@@index([awardDate])
}
Testing: Validation Against Real Data
We validated our calculators against real government tenders:
// tests/cidb-calculator.test.ts
import { calculateCidbGrade } from '@/lib/tools/cidb-calculator';
describe('CIDB Calculator', () => {
it('should calculate Grade 5 for typical mid-size contractor', () => {
const input = {
classOfWorks: 'GB',
bestTurnover: 7_000_000,
availableCapital: 1_500_000,
largestContract: 3_000_000,
};
const result = calculateCidbGrade(input);
expect(result.grade).toBe(5);
expect(result.maxContractValue).toBe(6_500_000);
});
it('should identify capital as limiting factor', () => {
const input = {
classOfWorks: 'CE',
bestTurnover: 50_000_000, // Qualifies for Grade 7
availableCapital: 500_000, // Only qualifies for Grade 2
largestContract: 10_000_000, // Qualifies for Grade 6
};
const result = calculateCidbGrade(input);
expect(result.grade).toBe(2);
expect(result.reason).toContain('capital');
});
});
User Feedback Loop
We added feedback collection to improve accuracy:
export function ToolResult({ result }: { result: ToolOutput }) {
const [feedback, setFeedback] = useState<'helpful' | 'not-helpful' | null>(null);
async function submitFeedback(rating: 'helpful' | 'not-helpful') {
setFeedback(rating);
await fetch('/api/tools/feedback', {
method: 'POST',
body: JSON.stringify({
toolName: 'cidb-calculator',
input: result.input,
output: result.output,
rating,
}),
});
}
return (
<div>
<ResultDisplay result={result} />
<div className="mt-4 flex gap-2">
<Button
variant={feedback === 'helpful' ? 'default' : 'outline'}
onClick={() => submitFeedback('helpful')}
>
π Helpful
</Button>
<Button
variant={feedback === 'not-helpful' ? 'default' : 'outline'}
onClick={() => submitFeedback('not-helpful')}
>
π Not Helpful
</Button>
</div>
</div>
);
}
This feedback helps us refine algorithms and prompts.
Lessons Learned
1. Explain Everything
Users don't just want answersβthey want to understand the logic:
// Good: Explains the "why"
{
grade: 5,
reason: "Your grade is limited by available capital (R1.5M). Turnover and contract history qualify for Grade 6, but capital only meets Grade 5 threshold (R6.5M required for Grade 6)."
}
// Bad: Just the number
{
grade: 5
}
2. Progressive Enhancement for Calculators
Start simple, add complexity:
// v1: Basic calculator
function calculateBbbee(ownership: number): number {
return ownership * 0.25; // Simplified
}
// v2: Add all elements
function calculateBbbee(input: BbbeeInput): BbbeeOutput {
// Full scorecard calculation
}
// v3: Add gap analysis
function calculateBbbee(input: BbbeeInput): BbbeeOutput {
const result = calculate(input);
result.gaps = identifyGaps(input);
return result;
}
3. Cache Aggressively for AI Tools
const CACHE_DURATION = {
'proposal-generation': 7 * 24 * 60 * 60, // 7 days
'value-estimation': 24 * 60 * 60, // 1 day
'historical-data': 30 * 24 * 60 * 60, // 30 days
};
4. Real-Time Validation > Post-Submit Errors
<Input
type="number"
{...register("blackOwnership", {
validate: (value) => {
if (value < 0) return "Cannot be negative";
if (value > 100) return "Cannot exceed 100%";
return true;
}
})}
/>
What's Next?
We're working on:
- ML-powered tender matching - Recommend tenders based on company capabilities
- Automated compliance checking - Scan uploaded documents for completeness
- Real-time collaboration - Multiple team members work on JV analysis together
- Mobile apps - Tools on the go
- API access - Let accounting firms integrate our calculators
Open Source Plans
We're planning to open source:
- β CIDB calculation library
- β B-BBEE calculator component
- β South African public holiday data (2025-2030)
- π JV fronting risk detection algorithm
- π Tender classification ML model
Would this be useful for your projects? Let me know!
Conclusion
Building these tools taught us that good tools educate while they calculate. Users shouldn't just get an answerβthey should understand the regulations, see where they stand, and learn what to improve.
The South African tender market is worth R500+ billion annually. By providing free, high-quality tools, we're helping businesses make data-driven decisions about which opportunities to pursue.
If you're building similar calculation-heavy applications, I hope this deep dive helps. Questions? Drop them in the comments!
Tech Stack:
- Next.js 14 + TypeScript
- Zustand for state management
- Recharts for data viz
- Anthropic Claude for AI generation
- OpenAI embeddings for similarity
- PostgreSQL + Prisma
- Redis for caching
Try the tools: tenders-sa.org/tools
What tools would help your business win more work? Share your ideas below!
Top comments (0)