Build campaign-analytics, financial-analyst, customer-success-manager, sales-engineer, and revenue-operations skills using the Claude Skills Factory workflow. Each skill includes SKILL.md, Python CLI tools, reference guides, and asset templates. All 16 Python scripts use standard library only with --format json/text support. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
558 lines
19 KiB
Python
558 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""RFP/RFI Response Analyzer - Score coverage, identify gaps, and recommend bid/no-bid.
|
|
|
|
Parses RFP/RFI requirements and scores coverage using Full/Partial/Planned/Gap
|
|
categories. Generates weighted coverage scores, gap analysis with mitigation
|
|
strategies, effort estimation, and bid/no-bid recommendations.
|
|
|
|
Usage:
|
|
python rfp_response_analyzer.py rfp_data.json
|
|
python rfp_response_analyzer.py rfp_data.json --format json
|
|
python rfp_response_analyzer.py rfp_data.json --format text
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from typing import Any
|
|
|
|
|
|
# Coverage status to score mapping
|
|
COVERAGE_SCORES: dict[str, float] = {
|
|
"full": 1.0,
|
|
"partial": 0.5,
|
|
"planned": 0.25,
|
|
"gap": 0.0,
|
|
}
|
|
|
|
# Priority to weight mapping
|
|
PRIORITY_WEIGHTS: dict[str, float] = {
|
|
"must-have": 3.0,
|
|
"should-have": 2.0,
|
|
"nice-to-have": 1.0,
|
|
}
|
|
|
|
# Bid thresholds
|
|
BID_THRESHOLD = 0.70
|
|
CONDITIONAL_THRESHOLD = 0.50
|
|
MAX_MUST_HAVE_GAPS_FOR_BID = 3
|
|
|
|
|
|
def safe_divide(numerator: float, denominator: float, default: float = 0.0) -> float:
|
|
"""Safely divide two numbers, returning default if denominator is zero."""
|
|
if denominator == 0:
|
|
return default
|
|
return numerator / denominator
|
|
|
|
|
|
def load_rfp_data(filepath: str) -> dict[str, Any]:
|
|
"""Load and validate RFP data from a JSON file.
|
|
|
|
Args:
|
|
filepath: Path to the JSON file containing RFP data.
|
|
|
|
Returns:
|
|
Parsed RFP data dictionary.
|
|
|
|
Raises:
|
|
SystemExit: If the file cannot be read or parsed.
|
|
"""
|
|
try:
|
|
with open(filepath, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
except FileNotFoundError:
|
|
print(f"Error: File not found: {filepath}", file=sys.stderr)
|
|
sys.exit(1)
|
|
except json.JSONDecodeError as e:
|
|
print(f"Error: Invalid JSON in {filepath}: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if "requirements" not in data:
|
|
print("Error: JSON must contain a 'requirements' array.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
return data
|
|
|
|
|
|
def analyze_requirement(req: dict[str, Any]) -> dict[str, Any]:
|
|
"""Analyze a single requirement and compute its score.
|
|
|
|
Args:
|
|
req: Requirement dictionary with category, priority, coverage_status, etc.
|
|
|
|
Returns:
|
|
Enriched requirement with computed score and weight.
|
|
"""
|
|
coverage_status = req.get("coverage_status", "gap").lower()
|
|
priority = req.get("priority", "nice-to-have").lower()
|
|
|
|
coverage_score = COVERAGE_SCORES.get(coverage_status, 0.0)
|
|
weight = PRIORITY_WEIGHTS.get(priority, 1.0)
|
|
weighted_score = coverage_score * weight
|
|
max_weighted = weight
|
|
|
|
effort_hours = req.get("effort_hours", 0)
|
|
|
|
result = {
|
|
"id": req.get("id", "unknown"),
|
|
"requirement": req.get("requirement", "Unnamed requirement"),
|
|
"category": req.get("category", "Uncategorized"),
|
|
"priority": priority,
|
|
"coverage_status": coverage_status,
|
|
"coverage_score": coverage_score,
|
|
"weight": weight,
|
|
"weighted_score": weighted_score,
|
|
"max_weighted": max_weighted,
|
|
"effort_hours": effort_hours,
|
|
"notes": req.get("notes", ""),
|
|
"mitigation": req.get("mitigation", ""),
|
|
}
|
|
|
|
return result
|
|
|
|
|
|
def generate_gap_analysis(analyzed_reqs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
"""Generate gap analysis for requirements not fully covered.
|
|
|
|
Args:
|
|
analyzed_reqs: List of analyzed requirement dictionaries.
|
|
|
|
Returns:
|
|
List of gap entries with mitigation strategies.
|
|
"""
|
|
gaps = []
|
|
for req in analyzed_reqs:
|
|
if req["coverage_status"] in ("gap", "partial", "planned"):
|
|
severity = "critical" if req["priority"] == "must-have" else (
|
|
"high" if req["priority"] == "should-have" else "low"
|
|
)
|
|
|
|
mitigation = req["mitigation"]
|
|
if not mitigation:
|
|
if req["coverage_status"] == "partial":
|
|
mitigation = "Enhance existing capability to achieve full coverage"
|
|
elif req["coverage_status"] == "planned":
|
|
mitigation = "Communicate roadmap timeline and interim workaround"
|
|
else:
|
|
mitigation = "Evaluate build vs. partner vs. no-bid for this requirement"
|
|
|
|
gaps.append({
|
|
"id": req["id"],
|
|
"requirement": req["requirement"],
|
|
"category": req["category"],
|
|
"priority": req["priority"],
|
|
"coverage_status": req["coverage_status"],
|
|
"severity": severity,
|
|
"effort_hours": req["effort_hours"],
|
|
"mitigation": mitigation,
|
|
})
|
|
|
|
# Sort by severity: critical > high > low
|
|
severity_order = {"critical": 0, "high": 1, "low": 2}
|
|
gaps.sort(key=lambda g: severity_order.get(g["severity"], 3))
|
|
|
|
return gaps
|
|
|
|
|
|
def compute_category_scores(analyzed_reqs: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
"""Compute coverage scores grouped by requirement category.
|
|
|
|
Args:
|
|
analyzed_reqs: List of analyzed requirement dictionaries.
|
|
|
|
Returns:
|
|
Dictionary of category names to score summaries.
|
|
"""
|
|
categories: dict[str, dict[str, float]] = {}
|
|
|
|
for req in analyzed_reqs:
|
|
cat = req["category"]
|
|
if cat not in categories:
|
|
categories[cat] = {
|
|
"weighted_score": 0.0,
|
|
"max_weighted": 0.0,
|
|
"count": 0,
|
|
"full_count": 0,
|
|
"partial_count": 0,
|
|
"planned_count": 0,
|
|
"gap_count": 0,
|
|
"effort_hours": 0,
|
|
}
|
|
|
|
categories[cat]["weighted_score"] += req["weighted_score"]
|
|
categories[cat]["max_weighted"] += req["max_weighted"]
|
|
categories[cat]["count"] += 1
|
|
categories[cat]["effort_hours"] += req["effort_hours"]
|
|
|
|
status_key = f"{req['coverage_status']}_count"
|
|
if status_key in categories[cat]:
|
|
categories[cat][status_key] += 1
|
|
|
|
result = {}
|
|
for cat, scores in categories.items():
|
|
coverage_pct = safe_divide(scores["weighted_score"], scores["max_weighted"]) * 100
|
|
result[cat] = {
|
|
"coverage_percentage": round(coverage_pct, 1),
|
|
"requirements_count": int(scores["count"]),
|
|
"full": int(scores["full_count"]),
|
|
"partial": int(scores["partial_count"]),
|
|
"planned": int(scores["planned_count"]),
|
|
"gap": int(scores["gap_count"]),
|
|
"effort_hours": int(scores["effort_hours"]),
|
|
}
|
|
|
|
return result
|
|
|
|
|
|
def determine_bid_recommendation(
|
|
overall_coverage: float,
|
|
must_have_gaps: int,
|
|
strategic_value: str,
|
|
) -> dict[str, Any]:
|
|
"""Determine bid/no-bid recommendation based on coverage and gaps.
|
|
|
|
Args:
|
|
overall_coverage: Overall weighted coverage percentage (0-100).
|
|
must_have_gaps: Number of must-have requirements with gap status.
|
|
strategic_value: Strategic value assessment (high, medium, low).
|
|
|
|
Returns:
|
|
Recommendation dictionary with decision and rationale.
|
|
"""
|
|
coverage_ratio = overall_coverage / 100.0
|
|
reasons = []
|
|
|
|
# Primary decision logic
|
|
if coverage_ratio >= BID_THRESHOLD and must_have_gaps <= MAX_MUST_HAVE_GAPS_FOR_BID:
|
|
decision = "BID"
|
|
reasons.append(f"Coverage score {overall_coverage:.1f}% exceeds {BID_THRESHOLD*100:.0f}% threshold")
|
|
if must_have_gaps > 0:
|
|
reasons.append(f"{must_have_gaps} must-have gap(s) within acceptable range (max {MAX_MUST_HAVE_GAPS_FOR_BID})")
|
|
elif coverage_ratio >= CONDITIONAL_THRESHOLD or (
|
|
must_have_gaps <= MAX_MUST_HAVE_GAPS_FOR_BID and coverage_ratio >= 0.4
|
|
):
|
|
decision = "CONDITIONAL BID"
|
|
reasons.append(f"Coverage score {overall_coverage:.1f}% in conditional range ({CONDITIONAL_THRESHOLD*100:.0f}%-{BID_THRESHOLD*100:.0f}%)")
|
|
if must_have_gaps > 0:
|
|
reasons.append(f"{must_have_gaps} must-have gap(s) require mitigation plan")
|
|
else:
|
|
decision = "NO-BID"
|
|
if coverage_ratio < CONDITIONAL_THRESHOLD:
|
|
reasons.append(f"Coverage score {overall_coverage:.1f}% below {CONDITIONAL_THRESHOLD*100:.0f}% minimum")
|
|
if must_have_gaps > MAX_MUST_HAVE_GAPS_FOR_BID:
|
|
reasons.append(f"{must_have_gaps} must-have gaps exceed maximum of {MAX_MUST_HAVE_GAPS_FOR_BID}")
|
|
|
|
# Strategic value adjustment
|
|
if strategic_value.lower() == "high" and decision == "CONDITIONAL BID":
|
|
reasons.append("High strategic value supports pursuing despite coverage gaps")
|
|
elif strategic_value.lower() == "low" and decision == "CONDITIONAL BID":
|
|
decision = "NO-BID"
|
|
reasons.append("Low strategic value does not justify investment for conditional coverage")
|
|
|
|
confidence = "high" if coverage_ratio >= 0.80 else (
|
|
"medium" if coverage_ratio >= 0.60 else "low"
|
|
)
|
|
|
|
return {
|
|
"decision": decision,
|
|
"confidence": confidence,
|
|
"overall_coverage_percentage": round(overall_coverage, 1),
|
|
"must_have_gaps": must_have_gaps,
|
|
"strategic_value": strategic_value,
|
|
"reasons": reasons,
|
|
}
|
|
|
|
|
|
def generate_risk_assessment(
|
|
analyzed_reqs: list[dict[str, Any]],
|
|
gaps: list[dict[str, Any]],
|
|
) -> list[dict[str, str]]:
|
|
"""Generate risk assessment based on gaps and coverage patterns.
|
|
|
|
Args:
|
|
analyzed_reqs: List of analyzed requirement dictionaries.
|
|
gaps: List of gap analysis entries.
|
|
|
|
Returns:
|
|
List of risk entries with impact and mitigation.
|
|
"""
|
|
risks = []
|
|
|
|
critical_gaps = [g for g in gaps if g["severity"] == "critical"]
|
|
if critical_gaps:
|
|
risks.append({
|
|
"risk": "Critical requirement gaps",
|
|
"impact": "high",
|
|
"description": f"{len(critical_gaps)} must-have requirements not fully met",
|
|
"mitigation": "Prioritize engineering effort or partner integration for gap closure",
|
|
})
|
|
|
|
total_effort = sum(r["effort_hours"] for r in analyzed_reqs if r["coverage_status"] != "full")
|
|
if total_effort > 200:
|
|
risks.append({
|
|
"risk": "High customization effort",
|
|
"impact": "high",
|
|
"description": f"{total_effort} hours estimated for non-full requirements",
|
|
"mitigation": "Evaluate resource availability and timeline feasibility before committing",
|
|
})
|
|
elif total_effort > 80:
|
|
risks.append({
|
|
"risk": "Moderate customization effort",
|
|
"impact": "medium",
|
|
"description": f"{total_effort} hours estimated for non-full requirements",
|
|
"mitigation": "Phase implementation and set clear expectations on delivery timeline",
|
|
})
|
|
|
|
planned_count = sum(1 for r in analyzed_reqs if r["coverage_status"] == "planned")
|
|
if planned_count > 3:
|
|
risks.append({
|
|
"risk": "Roadmap dependency",
|
|
"impact": "medium",
|
|
"description": f"{planned_count} requirements depend on planned product features",
|
|
"mitigation": "Confirm roadmap timelines with product team; include contractual commitments if needed",
|
|
})
|
|
|
|
partial_count = sum(1 for r in analyzed_reqs if r["coverage_status"] == "partial")
|
|
if partial_count > 5:
|
|
risks.append({
|
|
"risk": "Workaround complexity",
|
|
"impact": "medium",
|
|
"description": f"{partial_count} requirements need workarounds or configuration",
|
|
"mitigation": "Document workarounds clearly; plan for native support in future releases",
|
|
})
|
|
|
|
if not risks:
|
|
risks.append({
|
|
"risk": "No significant risks identified",
|
|
"impact": "low",
|
|
"description": "Strong coverage across all requirement categories",
|
|
"mitigation": "Maintain standard engagement process",
|
|
})
|
|
|
|
return risks
|
|
|
|
|
|
def analyze_rfp(data: dict[str, Any]) -> dict[str, Any]:
|
|
"""Run the complete RFP analysis pipeline.
|
|
|
|
Args:
|
|
data: Parsed RFP data with requirements array.
|
|
|
|
Returns:
|
|
Complete analysis results dictionary.
|
|
"""
|
|
rfp_info = {
|
|
"rfp_name": data.get("rfp_name", "Unnamed RFP"),
|
|
"customer": data.get("customer", "Unknown Customer"),
|
|
"due_date": data.get("due_date", "Not specified"),
|
|
"strategic_value": data.get("strategic_value", "medium"),
|
|
"deal_value": data.get("deal_value", "Not specified"),
|
|
}
|
|
|
|
# Analyze each requirement
|
|
analyzed_reqs = [analyze_requirement(req) for req in data["requirements"]]
|
|
|
|
# Compute overall scores
|
|
total_weighted = sum(r["weighted_score"] for r in analyzed_reqs)
|
|
total_max = sum(r["max_weighted"] for r in analyzed_reqs)
|
|
overall_coverage = safe_divide(total_weighted, total_max) * 100
|
|
|
|
# Coverage summary
|
|
total_count = len(analyzed_reqs)
|
|
full_count = sum(1 for r in analyzed_reqs if r["coverage_status"] == "full")
|
|
partial_count = sum(1 for r in analyzed_reqs if r["coverage_status"] == "partial")
|
|
planned_count = sum(1 for r in analyzed_reqs if r["coverage_status"] == "planned")
|
|
gap_count = sum(1 for r in analyzed_reqs if r["coverage_status"] == "gap")
|
|
|
|
# Must-have gap count
|
|
must_have_gaps = sum(
|
|
1 for r in analyzed_reqs
|
|
if r["priority"] == "must-have" and r["coverage_status"] == "gap"
|
|
)
|
|
|
|
# Category breakdown
|
|
category_scores = compute_category_scores(analyzed_reqs)
|
|
|
|
# Gap analysis
|
|
gaps = generate_gap_analysis(analyzed_reqs)
|
|
|
|
# Bid recommendation
|
|
bid_recommendation = determine_bid_recommendation(
|
|
overall_coverage,
|
|
must_have_gaps,
|
|
rfp_info["strategic_value"],
|
|
)
|
|
|
|
# Risk assessment
|
|
risks = generate_risk_assessment(analyzed_reqs, gaps)
|
|
|
|
# Effort summary
|
|
total_effort = sum(r["effort_hours"] for r in analyzed_reqs)
|
|
gap_effort = sum(r["effort_hours"] for r in analyzed_reqs if r["coverage_status"] != "full")
|
|
|
|
return {
|
|
"rfp_info": rfp_info,
|
|
"coverage_summary": {
|
|
"overall_coverage_percentage": round(overall_coverage, 1),
|
|
"total_requirements": total_count,
|
|
"full": full_count,
|
|
"partial": partial_count,
|
|
"planned": planned_count,
|
|
"gap": gap_count,
|
|
"must_have_gaps": must_have_gaps,
|
|
},
|
|
"category_scores": category_scores,
|
|
"bid_recommendation": bid_recommendation,
|
|
"gap_analysis": gaps,
|
|
"risk_assessment": risks,
|
|
"effort_estimate": {
|
|
"total_hours": total_effort,
|
|
"gap_closure_hours": gap_effort,
|
|
"full_coverage_hours": total_effort - gap_effort,
|
|
},
|
|
"requirements_detail": analyzed_reqs,
|
|
}
|
|
|
|
|
|
def format_text(result: dict[str, Any]) -> str:
|
|
"""Format analysis results as human-readable text.
|
|
|
|
Args:
|
|
result: Complete analysis results dictionary.
|
|
|
|
Returns:
|
|
Formatted text string.
|
|
"""
|
|
lines = []
|
|
info = result["rfp_info"]
|
|
lines.append("=" * 70)
|
|
lines.append("RFP RESPONSE ANALYSIS")
|
|
lines.append("=" * 70)
|
|
lines.append(f"RFP: {info['rfp_name']}")
|
|
lines.append(f"Customer: {info['customer']}")
|
|
lines.append(f"Due Date: {info['due_date']}")
|
|
lines.append(f"Deal Value: {info['deal_value']}")
|
|
lines.append(f"Strategic Value: {info['strategic_value'].upper()}")
|
|
lines.append("")
|
|
|
|
# Coverage summary
|
|
cs = result["coverage_summary"]
|
|
lines.append("-" * 70)
|
|
lines.append("COVERAGE SUMMARY")
|
|
lines.append("-" * 70)
|
|
lines.append(f"Overall Coverage: {cs['overall_coverage_percentage']}%")
|
|
lines.append(f"Total Requirements: {cs['total_requirements']}")
|
|
lines.append(f" Full: {cs['full']} | Partial: {cs['partial']} | Planned: {cs['planned']} | Gap: {cs['gap']}")
|
|
lines.append(f"Must-Have Gaps: {cs['must_have_gaps']}")
|
|
lines.append("")
|
|
|
|
# Bid recommendation
|
|
bid = result["bid_recommendation"]
|
|
lines.append("-" * 70)
|
|
lines.append(f"BID RECOMMENDATION: {bid['decision']}")
|
|
lines.append(f"Confidence: {bid['confidence'].upper()}")
|
|
lines.append("-" * 70)
|
|
for reason in bid["reasons"]:
|
|
lines.append(f" - {reason}")
|
|
lines.append("")
|
|
|
|
# Category scores
|
|
lines.append("-" * 70)
|
|
lines.append("CATEGORY BREAKDOWN")
|
|
lines.append("-" * 70)
|
|
lines.append(f"{'Category':<25} {'Coverage':>8} {'Full':>5} {'Part':>5} {'Plan':>5} {'Gap':>5} {'Effort':>7}")
|
|
lines.append("-" * 70)
|
|
for cat, scores in result["category_scores"].items():
|
|
lines.append(
|
|
f"{cat:<25} {scores['coverage_percentage']:>7.1f}% "
|
|
f"{scores['full']:>5} {scores['partial']:>5} "
|
|
f"{scores['planned']:>5} {scores['gap']:>5} "
|
|
f"{scores['effort_hours']:>6}h"
|
|
)
|
|
lines.append("")
|
|
|
|
# Gap analysis
|
|
gaps = result["gap_analysis"]
|
|
if gaps:
|
|
lines.append("-" * 70)
|
|
lines.append("GAP ANALYSIS")
|
|
lines.append("-" * 70)
|
|
for gap in gaps:
|
|
severity_marker = "!!!" if gap["severity"] == "critical" else (
|
|
"!!" if gap["severity"] == "high" else "!"
|
|
)
|
|
lines.append(f" [{severity_marker}] {gap['id']}: {gap['requirement']}")
|
|
lines.append(f" Category: {gap['category']} | Priority: {gap['priority']} | Status: {gap['coverage_status']}")
|
|
lines.append(f" Effort: {gap['effort_hours']}h | Mitigation: {gap['mitigation']}")
|
|
lines.append("")
|
|
|
|
# Risk assessment
|
|
risks = result["risk_assessment"]
|
|
lines.append("-" * 70)
|
|
lines.append("RISK ASSESSMENT")
|
|
lines.append("-" * 70)
|
|
for risk in risks:
|
|
lines.append(f" [{risk['impact'].upper()}] {risk['risk']}")
|
|
lines.append(f" {risk['description']}")
|
|
lines.append(f" Mitigation: {risk['mitigation']}")
|
|
lines.append("")
|
|
|
|
# Effort estimate
|
|
effort = result["effort_estimate"]
|
|
lines.append("-" * 70)
|
|
lines.append("EFFORT ESTIMATE")
|
|
lines.append("-" * 70)
|
|
lines.append(f" Total Effort: {effort['total_hours']} hours")
|
|
lines.append(f" Gap Closure Effort: {effort['gap_closure_hours']} hours")
|
|
lines.append(f" Supported Effort: {effort['full_coverage_hours']} hours")
|
|
lines.append("")
|
|
lines.append("=" * 70)
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main() -> None:
|
|
"""Main entry point for the RFP Response Analyzer."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Analyze RFP/RFI requirements for coverage, gaps, and bid recommendation.",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=(
|
|
"Coverage Categories:\n"
|
|
" Full (100%) - Requirement fully met\n"
|
|
" Partial (50%) - Partially met, workaround needed\n"
|
|
" Planned (25%) - On roadmap, not yet available\n"
|
|
" Gap (0%) - Not supported\n"
|
|
"\n"
|
|
"Priority Weights:\n"
|
|
" Must-Have (3x) | Should-Have (2x) | Nice-to-Have (1x)\n"
|
|
"\n"
|
|
"Example:\n"
|
|
" python rfp_response_analyzer.py rfp_data.json --format json\n"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"input_file",
|
|
help="Path to JSON file containing RFP requirements data",
|
|
)
|
|
parser.add_argument(
|
|
"--format",
|
|
choices=["json", "text"],
|
|
default="text",
|
|
dest="output_format",
|
|
help="Output format: json or text (default: text)",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
data = load_rfp_data(args.input_file)
|
|
result = analyze_rfp(data)
|
|
|
|
if args.output_format == "json":
|
|
print(json.dumps(result, indent=2))
|
|
else:
|
|
print(format_text(result))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|