Files
Anton_wireframe/app/services/report_gen.py
T

341 lines
13 KiB
Python

from pathlib import Path
from typing import Any, Dict, List, Optional
# Import database models and compatibility score service
from db.models import InvestorTable, ProjectTable
from jinja2 import Environment, FileSystemLoader
from playwright.async_api import async_playwright
from services.compatibility_score import calculate_project_investor_compatibility
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,
investor_model: Optional[InvestorTable] = None,
project_model: Optional[ProjectTable] = 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
investor_model: Optional database model for investor (used for compatibility scoring)
project_model: Optional database model for project (used for compatibility scoring)
Returns:
bytes: PDF file content
"""
# Prepare template context
context = self._prepare_context(
investor_data, project_data, investor_model, project_model
)
# 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,
investor_model: Optional[InvestorTable] = None,
project_model: Optional[ProjectTable] = 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:
# Use the compatibility_score service if models are provided
if investor_model and project_model:
# Calculate using the standardized compatibility score service
# Returns score between 0 and 1, convert to percentage (0-100)
score_decimal = calculate_project_investor_compatibility(
project=project_model, investor=investor_model, use_funds=True
)
context["compatibility_score"] = int(score_decimal * 100)
else:
# Fallback to old calculation method if models not provided
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": 20,
}
# Aggregate data from all funds
all_sectors = set(investor_data.get("sectors", []))
all_stages = set()
all_geographies = []
check_ranges = []
for fund in investor_data.get("funds", []):
all_sectors.update(fund.get("sectors", []))
all_stages.update(fund.get("investment_stages", []))
if fund.get("geographic_focus"):
all_geographies.append(fund["geographic_focus"])
if fund.get("check_size_lower") and fund.get("check_size_upper"):
check_ranges.append(
{
"lower": fund["check_size_lower"],
"upper": fund["check_size_upper"],
}
)
# Sector match
project_sectors = set(project_data.get("sectors", []))
if all_sectors and project_sectors:
if all_sectors & project_sectors:
score += weights["sector"]
# Stage match - case insensitive comparison
project_stage = project_data.get("stage")
if project_stage and all_stages:
# Normalize stage names for comparison (case-insensitive)
normalized_stages = {
stage.lower().replace("_", " ") for stage in all_stages
}
project_stage_normalized = project_stage.lower().replace("_", " ")
if project_stage_normalized in normalized_stages:
score += weights["stage"]
# Geography match - check if any fund matches
project_geo = (project_data.get("location") or "").lower()
geo_match = False
if all_geographies:
for geo in all_geographies:
if geo:
geo_lower = geo.lower()
# Match if investor geography is "global" or if there's a location overlap
if "global" in geo_lower or "worldwide" in geo_lower:
geo_match = True
break
if project_geo and (
geo_lower in project_geo or project_geo in geo_lower
):
geo_match = True
break
if geo_match:
score += weights["geography"]
# Check size match - check if any fund's range matches
project_valuation = project_data.get("valuation", 0)
check_match = False
if project_valuation and check_ranges:
for check_range in check_ranges:
if check_range["lower"] <= project_valuation <= check_range["upper"]:
check_match = True
break
if check_match:
score += weights["check_size"]
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 = []
# Aggregate data from all funds
all_sectors = set(investor_data.get("sectors", []))
all_stages = set()
all_geographies = []
check_ranges = []
for fund in investor_data.get("funds", []):
all_sectors.update(fund.get("sectors", []))
all_stages.update(fund.get("investment_stages", []))
if fund.get("geographic_focus"):
all_geographies.append(fund["geographic_focus"])
if fund.get("check_size_lower") and fund.get("check_size_upper"):
check_ranges.append(
{
"lower": fund["check_size_lower"],
"upper": fund["check_size_upper"],
"fund_name": fund.get("fund_name", "Unnamed Fund"),
}
)
# Sector criterion
project_sectors = project_data.get("sectors", [])
sector_match = "Perfect" if all_sectors & set(project_sectors) else "Mismatch"
criteria.append(
{
"name": "Sector",
"requirement": ", ".join(project_sectors) if project_sectors else "N/A",
"evidence": ", ".join(list(all_sectors)[:3]) if all_sectors else "N/A",
"match": sector_match,
"weight": "30%",
}
)
# Stage criterion - case insensitive comparison
project_stage = project_data.get("stage", "N/A")
stage_match = "Mismatch"
if project_stage != "N/A" and all_stages:
# Normalize stage names for comparison
normalized_stages = {
stage.lower().replace("_", " ") for stage in all_stages
}
project_stage_normalized = project_stage.lower().replace("_", " ")
stage_match = (
"Perfect"
if project_stage_normalized in normalized_stages
else "Mismatch"
)
elif project_stage == "N/A":
stage_match = "N/A"
criteria.append(
{
"name": "Stage",
"requirement": str(project_stage),
"evidence": ", ".join(all_stages) if all_stages else "N/A",
"match": stage_match,
"weight": "30%",
}
)
# Geography criterion
project_geo = project_data.get("location") or "N/A"
investor_geo_display = ", ".join(all_geographies) if all_geographies else "N/A"
# Safe comparison handling None values and "Global" matches
geo_match = "Mismatch"
if project_geo != "N/A" and all_geographies:
for geo in all_geographies:
if geo:
geo_lower = geo.lower()
# Match if investor geography is "global" or if there's a location overlap
if "global" in geo_lower or "worldwide" in geo_lower:
geo_match = "Perfect"
break
if (
geo_lower in project_geo.lower()
or project_geo.lower() in geo_lower
):
geo_match = "Strong"
break
elif not all_geographies and project_geo == "N/A":
geo_match = "N/A"
criteria.append(
{
"name": "Geography",
"requirement": project_geo,
"evidence": investor_geo_display,
"match": geo_match,
"weight": "20%",
}
)
# Check Size criterion
project_val = project_data.get("valuation", 0)
# Build evidence string from all fund ranges
check_evidence = "N/A"
if check_ranges:
evidence_parts = []
for cr in check_ranges[:3]: # Show up to 3 funds
range_str = (
f"{cr['lower'] / 1000000:.0f}M - €{cr['upper'] / 1000000:.0f}M"
)
if cr["fund_name"]:
evidence_parts.append(f"{cr['fund_name']}: {range_str}")
else:
evidence_parts.append(range_str)
check_evidence = "; ".join(evidence_parts)
# Check if project valuation matches any fund
check_match = "N/A"
if project_val > 0 and check_ranges:
match_found = any(
cr["lower"] <= project_val <= cr["upper"] for cr in check_ranges
)
check_match = "Perfect" if match_found else "Mismatch"
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": "20%",
}
)
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