feat: Implement report generation service and add report route for investor profiles

This commit is contained in:
bolade
2025-10-14 12:02:23 +01:00
parent e386ebbdef
commit 9e1ec258f1
10 changed files with 697 additions and 1 deletions
Binary file not shown.
+9 -1
View File
@@ -5,7 +5,14 @@ from db.db import Base, db_dependency, engine
from dotenv import load_dotenv
from fastapi import FastAPI, File, Form, UploadFile
from pydantic import BaseModel
from routers import companies, folk_crm, insight_route, investors, projects
from routers import (
companies,
folk_crm,
insight_route,
investors,
projects,
report_route,
)
from schemas.router_schemas import InvestmentResponse, PaginatedResponse
from services.llm_parser import InvestorProcessor
from services.querying import QueryProcessor
@@ -110,6 +117,7 @@ app.include_router(companies.router)
app.include_router(projects.router)
app.include_router(folk_crm.router)
app.include_router(insight_route.router)
app.include_router(report_route.router)
if __name__ == "__main__":
import uvicorn
Binary file not shown.
Binary file not shown.
+121
View File
@@ -0,0 +1,121 @@
from typing import Optional
from db.db import get_db
from db.models import FundTable, InvestorTable, ProjectTable
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response
from services.report_gen import ReportGenerator
from sqlalchemy.orm import Session, selectinload
router = APIRouter(tags=["Report Generation"])
@router.get("/report/investor/{investor_id}")
async def generate_investor_report(
investor_id: int,
project_id: Optional[int] = Query(
None, description="Optional project ID for compatibility analysis"
),
db: Session = Depends(get_db),
):
"""
Generate a PDF report for an investor profile.
Args:
investor_id: The ID of the investor to generate a report for
project_id: Optional project ID to include mandate match analysis
Returns:
PDF file as a downloadable response
"""
# Fetch investor data with all relationships
investor = (
db.query(InvestorTable)
.options(
selectinload(InvestorTable.portfolio_companies),
selectinload(InvestorTable.team_members),
selectinload(InvestorTable.sectors),
selectinload(InvestorTable.funds).selectinload(FundTable.investment_stages),
selectinload(InvestorTable.funds).selectinload(FundTable.sectors),
)
.filter(InvestorTable.id == investor_id)
.first()
)
if not investor:
raise HTTPException(status_code=404, detail="Investor not found")
# Prepare investor data dictionary
investor_data = {
"name": investor.name,
"description": investor.description,
"website": investor.website,
"headquarters": investor.headquarters,
"aum": investor.aum,
"geographic_focus": investor.geographic_focus,
"portfolio_highlights": investor.portfolio_highlights or [],
"investment_thesis": investor.investment_thesis or [],
"sectors": [sector.name for sector in investor.sectors],
"team_members": [
{
"name": member.name,
"role": member.role,
"title": member.title,
"email": member.email,
}
for member in investor.team_members
],
"check_size_lower": None,
"check_size_upper": None,
"investment_stages": [],
}
# Get check sizes and stages from funds
if investor.funds:
# Use the first fund's data or aggregate
fund = investor.funds[0]
investor_data["check_size_lower"] = fund.check_size_lower
investor_data["check_size_upper"] = fund.check_size_upper
# Aggregate all investment stages from all funds
stages = set()
for fund in investor.funds:
for stage in fund.investment_stages:
stages.add(stage.name)
investor_data["investment_stages"] = list(stages)
# Fetch project data if project_id is provided
project_data = None
if project_id:
project = (
db.query(ProjectTable)
.options(selectinload(ProjectTable.sector))
.filter(ProjectTable.id == project_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project_data = {
"name": project.name,
"description": project.description,
"location": project.location,
"valuation": project.valuation,
"stage": project.stage.name if project.stage else None,
"sectors": [sector.name for sector in project.sector],
}
# Generate PDF report
report_generator = ReportGenerator()
pdf_bytes = await report_generator.generate_investor_report(
investor_data, project_data
)
# Return PDF as downloadable file
filename = f"{investor.name.replace(' ', '_')}_Report.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
Binary file not shown.
Binary file not shown.
+247
View File
@@ -0,0 +1,247 @@
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", "").lower()
project_geo = project_data.get("location", "").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", 0)
check_upper = investor_data.get("check_size_upper", float("inf"))
if 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", "N/A")
project_geo = project_data.get("location", "N/A")
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", 0)
check_upper = investor_data.get("check_size_upper", 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 <= 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
+319
View File
@@ -0,0 +1,319 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Investor Profile Report</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@page {
margin: 0;
size: A4;
}
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, sans-serif;
}
.page {
page-break-after: always;
min-height: 100vh;
background: white;
}
.page:last-child {
page-break-after: auto;
}
.tag {
display: inline-block;
padding: 4px 12px;
background: #f3f4f6;
border-radius: 4px;
font-size: 12px;
margin: 4px;
}
</style>
</head>
<body class="bg-white">
<!-- Page 1: Investor Profile -->
<div class="page p-12">
<div class="flex justify-between items-start mb-8">
<div>
<p class="text-sm text-gray-600 mb-2">Investor Profile</p>
<h1 class="text-4xl font-bold text-gray-900">
{{ investor.name }}
</h1>
</div>
<button
class="bg-gray-200 text-gray-700 px-4 py-2 rounded text-sm"
>
<a
href="{{ investor.website }}"
target="_blank"
class="no-underline text-gray-700"
>Visit Website →</a
>
</button>
</div>
<div class="grid grid-cols-2 gap-8">
<!-- Left Column -->
<div>
<div class="mb-8">
<h2
class="text-sm font-bold text-gray-900 uppercase mb-4"
>
Investor Description
</h2>
<p class="text-sm text-gray-700 leading-relaxed">
{{ investor.description or 'No description
available.' }}
</p>
</div>
<div class="mb-8">
<h2
class="text-sm font-bold text-gray-900 uppercase mb-4"
>
Portfolio Highlights
</h2>
<div class="flex flex-wrap gap-2">
{% if investor.portfolio_highlights %} {% for
company in investor.portfolio_highlights[:5] %}
<span class="tag">{{ company }}</span>
{% endfor %} {% else %}
<p class="text-sm text-gray-500">
No portfolio highlights available
</p>
{% endif %}
</div>
</div>
<div class="mb-8">
<h2
class="text-sm font-bold text-gray-900 uppercase mb-4"
>
Senior Leadership
</h2>
{% if investor.team_members %} {% for member in
investor.team_members[:2] %}
<div class="mb-3">
<p class="text-sm font-semibold text-gray-900">
{{ member.name }}
</p>
<p class="text-sm text-gray-600">
{{ member.role or member.title or 'Team Member'
}}
</p>
{% if member.email %}
<p class="text-xs text-blue-600">
{{ member.email }}
</p>
{% endif %}
</div>
{% endfor %} {% else %}
<p class="text-sm text-gray-500">
No team information available
</p>
{% endif %}
</div>
</div>
<!-- Right Column -->
<div class="bg-gray-50 p-6 rounded-lg">
<h2 class="text-sm font-bold text-gray-900 uppercase mb-4">
Key Data
</h2>
<div class="mb-4">
<p class="text-xs text-gray-600 mb-1">Headquarters:</p>
<p class="text-sm font-semibold text-gray-900">
{{ investor.headquarters or 'N/A' }}
</p>
</div>
<div class="mb-4">
<p class="text-xs text-gray-600 mb-1">Sectors:</p>
<p class="text-sm font-semibold text-gray-900">
{% if investor.sectors %} {{ investor.sectors |
join(', ') }} {% else %} N/A {% endif %}
</p>
</div>
<div class="mb-4">
<p class="text-xs text-gray-600 mb-1">DACH Region:</p>
<p class="text-sm font-semibold text-gray-900">
{{ investor.geographic_focus or 'N/A' }}
</p>
</div>
<div class="mb-4">
<p class="text-xs text-gray-600 mb-1">
AUM: (EUR million) (as of Fund IX)
</p>
<p class="text-sm font-semibold text-gray-900">
{% if investor.aum %} €{{
'{:,.0f}'.format(investor.aum / 1000000) }}M {% else
%} N/A {% endif %}
</p>
</div>
<div class="mb-4">
<p class="text-xs text-gray-600 mb-1">
Investment Stage:
</p>
<p class="text-sm font-semibold text-gray-900">
{% if investor.investment_stages %} {{
investor.investment_stages | join(', ') }} {% else
%} N/A {% endif %}
</p>
</div>
<div class="mb-4">
<p class="text-xs text-gray-600 mb-1">
Est. Investment Size:
</p>
<p class="text-sm font-semibold text-gray-900">
{% if investor.check_size_lower and
investor.check_size_upper %} €{{
'{:,.0f}'.format(investor.check_size_lower /
1000000) }}M - €{{
'{:,.0f}'.format(investor.check_size_upper /
1000000) }}M {% elif investor.check_size_lower %}
€{{ '{:,.0f}'.format(investor.check_size_lower /
1000000) }}M+ {% else %} N/A {% endif %}
</p>
</div>
<div class="mb-4">
<p class="text-xs text-gray-600 mb-1">
Select Deals, Series A, Series B:
</p>
<p class="text-sm font-semibold text-gray-900">
Growth
</p>
</div>
<div class="mb-4">
<p class="text-xs text-gray-600 mb-1">Focus Areas:</p>
<p class="text-sm font-semibold text-gray-900">
{% if investor.investment_thesis %} {{
investor.investment_thesis[:3] | join(', ') }} {%
else %} Disruptive Technologies, Entrepreneur-led,
Sustainability {% endif %}
</p>
</div>
</div>
</div>
<div class="absolute bottom-12 right-12 text-xs text-gray-400">
Page 3
</div>
</div>
<!-- Page 2: Mandate Match Analysis -->
{% if project %}
<div class="page p-12">
<h1 class="text-3xl font-bold text-gray-900 mb-8">
{{ investor.name }}: Mandate Match Analysis
</h1>
<!-- Overall Match Circle -->
<div class="flex justify-center mb-12">
<div class="text-center">
<p class="text-sm font-bold text-gray-700 uppercase mb-4">
Overall Mandate Match
</p>
<div
class="w-48 h-48 rounded-full border-8 border-green-400 flex items-center justify-center bg-green-50 mx-auto"
>
<span class="text-5xl font-bold text-green-600"
>{{ compatibility_score }}%</span
>
</div>
</div>
</div>
<!-- Mandate Alignment Analysis Table -->
<div class="mb-12">
<h2 class="text-xl font-bold text-gray-900 mb-6">
Mandate Alignment Analysis
</h2>
<table class="w-full border-collapse">
<thead>
<tr class="border-b-2 border-gray-300">
<th
class="text-left py-3 px-4 text-sm font-bold text-gray-700"
>
Criterion
</th>
<th
class="text-left py-3 px-4 text-sm font-bold text-gray-700"
>
Mandate Requirement
</th>
<th
class="text-left py-3 px-4 text-sm font-bold text-gray-700"
>
Investor Evidence (from Database)
</th>
<th
class="text-left py-3 px-4 text-sm font-bold text-gray-700"
>
Match Score
</th>
<th
class="text-left py-3 px-4 text-sm font-bold text-gray-700"
>
Weight
</th>
</tr>
</thead>
<tbody>
{% for criterion in match_criteria %}
<tr class="border-b border-gray-200">
<td class="py-4 px-4 text-sm text-gray-900">
{{ criterion.name }}
</td>
<td class="py-4 px-4 text-sm text-gray-700">
{{ criterion.requirement }}
</td>
<td class="py-4 px-4 text-sm text-gray-700">
{{ criterion.evidence }}
</td>
<td class="py-4 px-4 text-sm">
<span
class="{% if criterion.match == 'Perfect' %}text-green-600{% elif criterion.match == 'Strong' %}text-blue-600{% else %}text-yellow-600{% endif %} font-semibold"
>
{{ criterion.match }}
</span>
</td>
<td class="py-4 px-4 text-sm text-gray-700">
{{ criterion.weight }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Final Recommendation -->
<div class="bg-blue-50 border-l-4 border-blue-500 p-6 rounded">
<h3 class="text-lg font-bold text-gray-900 mb-3">
Final Recommendation & Rationale
</h3>
<p class="text-sm text-gray-700 leading-relaxed">
{{ recommendation or "High Priority. A strong target due to
exceptional alignment on the most heavily-weighted criteria:
Sector and Stage. The strong geographic fit in the DACH
region further solidifies this recommendation." }}
</p>
</div>
<div class="absolute bottom-12 right-12 text-xs text-gray-400">
Page 4
</div>
</div>
{% endif %}
</body>
</html>
+1
View File
@@ -48,6 +48,7 @@ jsonpointer==3.0.0
jsonschema==4.25.1
jsonschema-specifications==2025.4.1
kubernetes==33.1.0
playwright==1.48.0
langchain==0.3.27
langchain-community==0.3.29
langchain-core==0.3.75