260 lines
9.2 KiB
Python
260 lines
9.2 KiB
Python
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from jinja2 import Environment, FileSystemLoader
|
|
from playwright.async_api import async_playwright
|
|
|
|
|
|
class ReportGenerator:
|
|
"""Service for generating PDF reports from HTML templates"""
|
|
|
|
def __init__(self):
|
|
# Set up Jinja2 environment
|
|
template_dir = Path(__file__).parent.parent / "templates"
|
|
self.env = Environment(loader=FileSystemLoader(str(template_dir)))
|
|
|
|
async def generate_investor_report(
|
|
self,
|
|
investor_data: Dict[str, Any],
|
|
project_data: Optional[Dict[str, Any]] = None,
|
|
) -> bytes:
|
|
"""
|
|
Generate a PDF report for an investor profile.
|
|
|
|
Args:
|
|
investor_data: Dictionary containing investor information
|
|
project_data: Optional dictionary containing project information for compatibility analysis
|
|
|
|
Returns:
|
|
bytes: PDF file content
|
|
"""
|
|
# Prepare template context
|
|
context = self._prepare_context(investor_data, project_data)
|
|
|
|
# Render HTML from template
|
|
template = self.env.get_template("report.html")
|
|
html_content = template.render(**context)
|
|
# Convert HTML to PDF using Playwright
|
|
pdf_bytes = await self._html_to_pdf(html_content)
|
|
|
|
return pdf_bytes
|
|
|
|
def _prepare_context(
|
|
self,
|
|
investor_data: Dict[str, Any],
|
|
project_data: Optional[Dict[str, Any]] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Prepare the context dictionary for template rendering"""
|
|
context = {
|
|
"investor": investor_data,
|
|
"project": project_data,
|
|
"compatibility_score": 0,
|
|
"match_criteria": [],
|
|
"recommendation": None,
|
|
}
|
|
|
|
# If project data is provided, calculate compatibility
|
|
if project_data:
|
|
context["compatibility_score"] = self._calculate_compatibility_score(
|
|
investor_data, project_data
|
|
)
|
|
context["match_criteria"] = self._generate_match_criteria(
|
|
investor_data, project_data
|
|
)
|
|
context["recommendation"] = self._generate_recommendation(
|
|
context["compatibility_score"], context["match_criteria"]
|
|
)
|
|
|
|
return context
|
|
|
|
def _calculate_compatibility_score(
|
|
self, investor_data: Dict[str, Any], project_data: Dict[str, Any]
|
|
) -> int:
|
|
"""Calculate overall compatibility score between investor and project"""
|
|
score = 0
|
|
weights = {
|
|
"sector": 30,
|
|
"stage": 30,
|
|
"geography": 20,
|
|
"check_size": 15,
|
|
"thesis": 5,
|
|
}
|
|
|
|
# Sector match
|
|
investor_sectors = set(investor_data.get("sectors", []))
|
|
project_sectors = set(project_data.get("sectors", []))
|
|
if investor_sectors and project_sectors:
|
|
if investor_sectors & project_sectors:
|
|
score += weights["sector"]
|
|
|
|
# Stage match
|
|
investor_stages = set(investor_data.get("investment_stages", []))
|
|
project_stage = project_data.get("stage")
|
|
if project_stage and project_stage in investor_stages:
|
|
score += weights["stage"]
|
|
|
|
# Geography match
|
|
investor_geo = (investor_data.get("geographic_focus") or "").lower()
|
|
project_geo = (project_data.get("location") or "").lower()
|
|
if investor_geo and project_geo and investor_geo in project_geo:
|
|
score += weights["geography"]
|
|
|
|
# Check size match
|
|
project_valuation = project_data.get("valuation", 0)
|
|
check_lower = investor_data.get("check_size_lower") or 0
|
|
check_upper = investor_data.get("check_size_upper") or float("inf")
|
|
if (
|
|
check_lower
|
|
and check_upper
|
|
and check_lower <= project_valuation <= check_upper
|
|
):
|
|
score += weights["check_size"]
|
|
|
|
# Thesis alignment (simplified)
|
|
score += weights["thesis"]
|
|
|
|
return min(score, 100)
|
|
|
|
def _generate_match_criteria(
|
|
self, investor_data: Dict[str, Any], project_data: Dict[str, Any]
|
|
) -> List[Dict[str, str]]:
|
|
"""Generate detailed match criteria table"""
|
|
criteria = []
|
|
|
|
# Sector criterion
|
|
investor_sectors = investor_data.get("sectors", [])
|
|
project_sectors = project_data.get("sectors", [])
|
|
sector_match = (
|
|
"Perfect" if set(investor_sectors) & set(project_sectors) else "Mismatch"
|
|
)
|
|
criteria.append(
|
|
{
|
|
"name": "Sector",
|
|
"requirement": "Cybersecurity, B2B SaaS" if project_sectors else "N/A",
|
|
"evidence": ", ".join(investor_sectors[:3])
|
|
if investor_sectors
|
|
else "N/A",
|
|
"match": sector_match,
|
|
"weight": "30%",
|
|
}
|
|
)
|
|
|
|
# Stage criterion
|
|
investor_stages = investor_data.get("investment_stages", [])
|
|
project_stage = project_data.get("stage", "N/A")
|
|
stage_match = "Perfect" if project_stage in investor_stages else "Mismatch"
|
|
criteria.append(
|
|
{
|
|
"name": "Stage",
|
|
"requirement": str(project_stage),
|
|
"evidence": ", ".join(investor_stages) if investor_stages else "N/A",
|
|
"match": stage_match,
|
|
"weight": "30%",
|
|
}
|
|
)
|
|
|
|
# Geography criterion
|
|
investor_geo = investor_data.get("geographic_focus") or "N/A"
|
|
project_geo = project_data.get("location") or "N/A"
|
|
|
|
# Safe comparison handling None values
|
|
if investor_geo == "N/A" or project_geo == "N/A":
|
|
geo_match = (
|
|
"N/A" if investor_geo == "N/A" and project_geo == "N/A" else "Mismatch"
|
|
)
|
|
else:
|
|
investor_geo_lower = investor_geo.lower()
|
|
project_geo_lower = project_geo.lower()
|
|
geo_match = (
|
|
"Strong"
|
|
if investor_geo_lower in project_geo_lower
|
|
or project_geo_lower in investor_geo_lower
|
|
else "Mismatch"
|
|
)
|
|
criteria.append(
|
|
{
|
|
"name": "Geography",
|
|
"requirement": project_geo,
|
|
"evidence": investor_geo,
|
|
"match": geo_match,
|
|
"weight": "20%",
|
|
}
|
|
)
|
|
|
|
# Check Size criterion
|
|
check_lower = investor_data.get("check_size_lower") or 0
|
|
check_upper = investor_data.get("check_size_upper") or 0
|
|
project_val = project_data.get("valuation", 0)
|
|
|
|
check_evidence = "N/A"
|
|
if check_lower and check_upper:
|
|
check_evidence = (
|
|
f"€{check_lower / 1000000:.0f}M - €{check_upper / 1000000:.0f}M"
|
|
)
|
|
elif check_lower:
|
|
check_evidence = f"€{check_lower / 1000000:.0f}M+"
|
|
|
|
check_match = (
|
|
"Perfect"
|
|
if check_lower and check_upper and check_lower <= project_val <= check_upper
|
|
else "Strong"
|
|
if project_val > 0
|
|
else "N/A"
|
|
)
|
|
criteria.append(
|
|
{
|
|
"name": "Check Size",
|
|
"requirement": f"€{project_val / 1000000:.0f}M"
|
|
if project_val
|
|
else "N/A",
|
|
"evidence": check_evidence,
|
|
"match": check_match,
|
|
"weight": "15%",
|
|
}
|
|
)
|
|
|
|
# Thesis criterion
|
|
thesis = investor_data.get("investment_thesis", [])
|
|
criteria.append(
|
|
{
|
|
"name": "Thesis",
|
|
"requirement": "Founder-led, ESG focus",
|
|
"evidence": ", ".join(thesis[:2]) if thesis else "Entrepreneur-led",
|
|
"match": "Strong",
|
|
"weight": "5%",
|
|
}
|
|
)
|
|
|
|
return criteria
|
|
|
|
def _generate_recommendation(
|
|
self, score: int, criteria: List[Dict[str, str]]
|
|
) -> str:
|
|
"""Generate recommendation text based on score and criteria"""
|
|
if score >= 85:
|
|
return "High Priority. A strong target due to exceptional alignment on the most heavily-weighted criteria: Sector and Stage. The strong geographic fit further solidifies this recommendation."
|
|
elif score >= 70:
|
|
return "Medium Priority. Good alignment on key criteria with some areas of strong fit. The geographic fit in the target region supports this recommendation."
|
|
else:
|
|
return "Low Priority. Limited alignment on key investment criteria. Consider for future evaluation if circumstances change."
|
|
|
|
async def _html_to_pdf(self, html_content: str) -> bytes:
|
|
"""Convert HTML content to PDF using Playwright"""
|
|
async with async_playwright() as p:
|
|
browser = await p.chromium.launch()
|
|
page = await browser.new_page()
|
|
|
|
# Set content and wait for any dynamic content to load
|
|
await page.set_content(html_content, wait_until="networkidle")
|
|
|
|
# Generate PDF with proper settings
|
|
pdf_bytes = await page.pdf(
|
|
format="A4",
|
|
print_background=True,
|
|
margin={"top": "0", "right": "0", "bottom": "0", "left": "0"},
|
|
)
|
|
|
|
await browser.close()
|
|
|
|
return pdf_bytes
|