rfp-evaluate

SKILL.md

RFP Evaluation Skill

Overview

This skill implements the 6-dimension scoring framework for evaluating RFP opportunities, with both logic-based (keyword matching) and AI-based evaluation modes.


CRITICAL: Execution Order

Eligibility Gate is P0 HIGHEST PRIORITY and runs BEFORE scoring.

┌─────────────────────────────────────────────────────────┐
│                 EVALUATION PIPELINE                      │
├─────────────────────────────────────────────────────────┤
│  1. ELIGIBILITY GATE (Hard filters - Phase 2)           │
│     ↓                                                    │
│     Output: ELIGIBLE | PARTNER_REQUIRED | REJECTED       │
│     ↓                                                    │
│  2. SCORING ENGINE (Only if eligible - Phase 3)         │
│     ↓                                                    │
│     Output: 0-6 score + Good Fit determination          │
└─────────────────────────────────────────────────────────┘

Implementation Plan References:

  • Eligibility Gate: docs/implementation-plan/phase-2-eligibility/
  • Scoring Engine: docs/implementation-plan/phase-3-scoring/

6-Dimension Scoring Framework

Dimension Weight Purpose
Technical Relevance 25% Tech stack alignment
Scope Fit 20% Project type match
Category Focus 15% Industry alignment
Client Profile 15% Client type match
Logistics 15% Practical feasibility
Skill Alignment 10% Team capability match

Criterion Configuration

interface Criterion {
  id: string;
  name: string;
  weight: number;           // 0-100, sum should = 100
  enabled: boolean;
  keywords: Keyword[];
  minMatches: number;       // Minimum keywords to meet criterion
  systemInstruction?: string; // For AI evaluation
}

interface Keyword {
  value: string;
  enabled: boolean;
  weight?: number;          // Optional keyword importance
}

Default Keywords

Technical Relevance (25%)

const TECHNICAL_KEYWORDS = [
  "aws", "azure", "gcp", "cloud", "serverless", "lambda",
  "kubernetes", "docker", "react", "nextjs", "typescript",
  "node", "api", "rest", "graphql", "microservices",
  "data platform", "analytics", "etl", "ci/cd", "devsecops"
];

Scope Fit (20%)

const SCOPE_KEYWORDS = [
  "website redesign", "web application", "portal development",
  "cms implementation", "platform modernization", "digital transformation",
  "cloud migration", "api development", "system integration",
  "data migration", "taxonomy", "information architecture"
];

Category Focus (15%)

const CATEGORY_KEYWORDS = [
  "public sector", "federal", "state", "local government",
  "it services", "software development", "digital services",
  "technology", "information technology"
];

Client Profile (15%)

const CLIENT_KEYWORDS = [
  "federal agency", "state agency", "municipality",
  "department of", "office of", "bureau of",
  "technology-forward", "agile", "modern"
];

Skill Alignment (10%)

const SKILL_KEYWORDS = [
  "frontend developer", "backend developer", "full-stack",
  "cloud architect", "devops engineer", "ux designer",
  "technical lead", "project manager", "qa engineer"
];

Eligibility Gate

Run BEFORE scoring to reject ineligible opportunities:

interface EligibilityResult {
  eligible: boolean;
  status: "ok" | "needs_partner" | "reject";
  disqualifiers: string[];
}

const HARD_DISQUALIFIERS = [
  { pattern: /security\s*clearance\s*(required|mandatory)/i, fatal: true },
  { pattern: /on-?site\s*(presence\s*)?(required|mandatory)/i, fatal: true },
  { pattern: /u\.?s\.?\s*(citizen|company|organization)\s*only/i, fatal: false }, // Can partner
];

function checkEligibility(text: string): EligibilityResult {
  const disqualifiers: string[] = [];
  let canPartner = true;

  for (const { pattern, fatal } of HARD_DISQUALIFIERS) {
    if (pattern.test(text)) {
      disqualifiers.push(pattern.source);
      if (fatal) canPartner = false;
    }
  }

  if (disqualifiers.length === 0) {
    return { eligible: true, status: "ok", disqualifiers: [] };
  }

  return {
    eligible: canPartner,
    status: canPartner ? "needs_partner" : "reject",
    disqualifiers,
  };
}

Logic-Based Evaluation

interface EvaluationResult {
  score: number;            // 0-100
  isFit: boolean;           // score >= 60
  criteriaResults: CriterionResult[];
  eligibility: EligibilityResult;
  reasoning?: string;
}

interface CriterionResult {
  criterionId: string;
  criterionName: string;
  weight: number;
  met: boolean;
  score: number;
  matchedKeywords: string[];
  details: string;
}

function evaluateLogically(
  rfp: RFP,
  criteria: Criterion[]
): EvaluationResult {
  const text = `${rfp.title} ${rfp.description}`.toLowerCase();
  const results: CriterionResult[] = [];
  let totalScore = 0;
  let totalWeight = 0;

  for (const criterion of criteria) {
    if (!criterion.enabled) continue;

    const enabledKeywords = criterion.keywords
      .filter(kw => kw.enabled)
      .map(kw => kw.value.toLowerCase());

    const matches = enabledKeywords.filter(kw => text.includes(kw));
    const met = matches.length >= criterion.minMatches;
    const score = met ? criterion.weight : 0;

    results.push({
      criterionId: criterion.id,
      criterionName: criterion.name,
      weight: criterion.weight,
      met,
      score,
      matchedKeywords: matches,
      details: met
        ? `Matched ${matches.length} keywords: ${matches.join(", ")}`
        : `Only ${matches.length}/${criterion.minMatches} required matches`,
    });

    totalScore += score;
    totalWeight += criterion.weight;
  }

  const normalizedScore = totalWeight > 0
    ? (totalScore / totalWeight) * 100
    : 0;

  return {
    score: Math.round(normalizedScore),
    isFit: normalizedScore >= 60,
    criteriaResults: results,
    eligibility: checkEligibility(text),
  };
}

AI-Based Evaluation

async function evaluateWithAI(
  rfp: RFP,
  criterion: Criterion,
  aiProvider: AIProvider
): Promise<CriterionResult> {
  const prompt = `
Analyze this RFP for ${criterion.name}.

RFP Title: ${rfp.title}
RFP Description: ${rfp.description}

Keywords to consider: ${criterion.keywords.map(k => k.value).join(", ")}

Evaluate if this RFP aligns with these keywords.
Consider both exact matches AND semantic relevance.

Respond with JSON only:
{
  "foundKeywords": ["keyword1", "keyword2"],
  "isMatch": true/false,
  "confidence": 0.0-1.0,
  "reasoning": "One sentence explanation"
}`;

  const systemInstruction = criterion.systemInstruction ??
    "You are an expert RFP analyst for a cloud-native software company.";

  const response = await aiProvider.analyze(prompt, systemInstruction);
  const parsed = JSON.parse(response);

  return {
    criterionId: criterion.id,
    criterionName: criterion.name,
    weight: criterion.weight,
    met: parsed.isMatch,
    score: parsed.isMatch ? criterion.weight : 0,
    matchedKeywords: parsed.foundKeywords,
    details: parsed.reasoning,
  };
}

Chaseability Score

Final composite score with recommendation:

interface ChaseabilityScore {
  overall: number;
  recommendation: "pursue" | "maybe" | "skip";
  reasoning: string;
  breakdown: Record<string, number>;
}

function calculateChaseability(
  evaluation: EvaluationResult
): ChaseabilityScore {
  // Apply partner penalty if needed
  const partnerPenalty = evaluation.eligibility.status === "needs_partner" ? 0.85 : 1.0;
  const adjustedScore = evaluation.score * partnerPenalty;

  // Determine recommendation
  let recommendation: "pursue" | "maybe" | "skip";
  if (!evaluation.eligibility.eligible) {
    recommendation = "skip";
  } else if (adjustedScore >= 70) {
    recommendation = "pursue";
  } else if (adjustedScore >= 50) {
    recommendation = "maybe";
  } else {
    recommendation = "skip";
  }

  // Build breakdown
  const breakdown: Record<string, number> = {};
  for (const result of evaluation.criteriaResults) {
    breakdown[result.criterionId] = result.score;
  }

  return {
    overall: Math.round(adjustedScore),
    recommendation,
    reasoning: buildReasoning(evaluation),
    breakdown,
  };
}

function buildReasoning(evaluation: EvaluationResult): string {
  const met = evaluation.criteriaResults.filter(r => r.met);
  const notMet = evaluation.criteriaResults.filter(r => !r.met);

  let reasoning = `Score: ${evaluation.score}%. `;
  reasoning += `Met ${met.length}/${evaluation.criteriaResults.length} criteria. `;

  if (notMet.length > 0) {
    reasoning += `Missing: ${notMet.map(r => r.criterionName).join(", ")}. `;
  }

  if (evaluation.eligibility.status === "needs_partner") {
    reasoning += "Note: Requires US partner for eligibility.";
  } else if (evaluation.eligibility.status === "reject") {
    reasoning += `Disqualified: ${evaluation.eligibility.disqualifiers.join(", ")}`;
  }

  return reasoning;
}

Convex Implementation

// convex/evaluations.ts
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";

export const evaluate = mutation({
  args: {
    rfpId: v.id("rfps"),
    evaluationType: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Not authenticated");

    const rfp = await ctx.db.get(args.rfpId);
    if (!rfp) throw new Error("RFP not found");

    // Get criteria configuration
    const criteria = await ctx.db.query("criteria").collect();

    // Run evaluation
    const evaluation = evaluateLogically(rfp, criteria);

    // Save result
    return await ctx.db.insert("evaluations", {
      rfpId: args.rfpId,
      userId: identity.subject,
      evaluationType: args.evaluationType ?? "logic",
      ...evaluation,
      evaluatedAt: Date.now(),
    });
  },
});

export const getByRfp = query({
  args: { rfpId: v.id("rfps") },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("evaluations")
      .withIndex("by_rfp", (q) => q.eq("rfpId", args.rfpId))
      .order("desc")
      .first();
  },
});

Score Display Guidelines

Score Range Color Badge Text
≥70% text-success (green) "Strong Fit"
50-69% text-warning (yellow) "Potential Fit"
<50% text-destructive (red) "Weak Fit"
function EvaluationBadge({ score }: { score: number }) {
  const variant = score >= 70 ? "success" : score >= 50 ? "warning" : "destructive";
  const label = score >= 70 ? "Strong Fit" : score >= 50 ? "Potential Fit" : "Weak Fit";

  return (
    <span className={`px-2 py-1 rounded text-sm bg-${variant}/20 text-${variant}`}>
      {score}% - {label}
    </span>
  );
}
Weekly Installs
4
First Seen
Feb 26, 2026
Installed on
gemini-cli4
github-copilot4
codex4
amp4
kimi-cli4
openclaw4