From 64f9364fcd2f393c5861590edb03b68f5f522dd0 Mon Sep 17 00:00:00 2001 From: bolade Date: Wed, 8 Oct 2025 19:21:46 +0100 Subject: [PATCH] feat: Integrate Folk CRM API for investor synchronization and compatibility scoring --- app/__pycache__/main.cpython-312.pyc | Bin 4932 -> 5023 bytes app/main.py | 3 +- .../__pycache__/investors.cpython-312.pyc | Bin 21921 -> 23593 bytes app/routers/folk_crm.py | 190 +++++++ app/routers/investors.py | 60 ++- .../__pycache__/querying.cpython-312.pyc | Bin 7350 -> 8315 bytes app/services/compatibility_score.py | 509 ++++++++++++++++++ app/services/crm.py | 260 +++++++++ app/services/querying.py | 47 +- 9 files changed, 1055 insertions(+), 14 deletions(-) create mode 100644 app/routers/folk_crm.py diff --git a/app/__pycache__/main.cpython-312.pyc b/app/__pycache__/main.cpython-312.pyc index ba7a3684bb908613a00926077d627d7473f6edc4..fcb4a864bafaaa50fac135d7484f66ffe4f53c42 100644 GIT binary patch delta 1063 zcmaJb}8Q&0=xI%XFgS(U>$ZjU=9wZwA~4#jqv2^};u z(&N+#%1!*?=xXF(r796_l`}_soS8t$-RNMST1HCy>LBKb#8*#OD6z6n9GT3dnQHQ= ziYCb#zvN6cL^IzTIy9gD&#nS0pu9|d%wm&&Egu|fj-zrTVwQTsp~DPyxO?Ql{Sal_ zYve&hk^h}dpP0>^n9U5bg?V#geU9UxL>tfBHMK_DR%_oQ&muc{A&48E)6rcE;UL!V z>~+lmr@t88;`tJJBTVr!`6|5Q%Vc|~S1i(^f!!9yDFot*=QgY+R7g#nz8&&2l)p1C zLY0Xo8B8%{6#Eb)HBEP2&)0p+bFroY6T@*&P#C9>=VDn54Hfc1tX~R6$CZKDLb~m@ z;4Iz2oUlY;i~E+yKDoa)s_Y30$o-4MxgkD7ZYJhN&ok#m1{cWV#9}(ULcF5`Mw)>) zV41v6{1neI!9xG7l#ATT(m0Sh;28*G?O_+eBPdgDJ_>7jX6fpbSDwWEhEy+-+ zS?&hHz6TgqNjkNeIs=!fDiCS2O&!A$d6{Z&LXvTLY_yQ0;|1`#J_2oj2`QR{G*e_T zf(?t-v0&Sd78VVVVVU${COp=1o51RZ5`$X|SWAOw6Zv7Q0-@t!KM-t-q9K@;;RjL6 z^>-B!c>W*i2ZFB`RYQ)nIH_4B5 URj7aA>U}9Q#*=t&gxd~!| zAtOt4?68+D5*uP+LqdYl11uFYEC@06&Jm?Wh?D&3-S@rk-Mi;|G5hYk_+Au~99@Uo zUw0o2L=8P3%~oeu zIpYsUcgf#7%N%@DVtH(~u9GAxPfgNjZDc-)_P8xI5T3$CvWc!L({;hl_G8CIW|)h- zL`o%#6^$BgwSit~2mGjpt>b(>VjKPFv7qu!C0;BcB)%^YL4~- zDg$Uxignpkp%{eccWQlv?Djtlb%>G)PR#_!xRkT7BMe+?}u$0GTQBu@WBJ^cujr0iD4u z25cq)-@yCyu5V;RU@s2V$!FPIW+M&aU0S&fUcf`w`)i=T5-V=o?mGAe+^4ySe*P9& WNEf3^AGxLBz4Tc`dYMv`4f_a|O0Y`+ diff --git a/app/main.py b/app/main.py index a923ecd..720bf20 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ 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, investors, projects +from routers import companies, folk_crm, investors, projects from schemas.router_schemas import InvestmentResponse, PaginatedResponse from services.llm_parser import InvestorProcessor from services.querying import QueryProcessor @@ -108,6 +108,7 @@ async def query_investors(request: QueryRequest): app.include_router(investors.router) app.include_router(companies.router) app.include_router(projects.router) +app.include_router(folk_crm.router) if __name__ == "__main__": import uvicorn diff --git a/app/routers/__pycache__/investors.cpython-312.pyc b/app/routers/__pycache__/investors.cpython-312.pyc index e67c075d5bd5567c50cd4de7c278ed63a653dc5d..3227140fda3abf9538fb0d7f0bfb1fb1e389b755 100644 GIT binary patch delta 7089 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 TK@Uz&nwEP{Tog>3!RG%1CnpD3 delta 5702 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 UJqnI;aY9_Y6@8XSJ&>LM0)Z=^a{vGU diff --git a/app/routers/folk_crm.py b/app/routers/folk_crm.py new file mode 100644 index 0000000..ae68ad5 --- /dev/null +++ b/app/routers/folk_crm.py @@ -0,0 +1,190 @@ +from typing import List + +from db.db import get_db +from db.models import InvestorTable +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from services.crm import folk +from sqlalchemy.orm import Session, selectinload + +router = APIRouter(prefix="/folk", tags=["Folk CRM"]) + + +class GroupResponse(BaseModel): + id: str + name: str + + +class SyncInvestorsRequest(BaseModel): + investor_ids: List[int] + group_id: str + + +class SyncResult(BaseModel): + investor_id: int + investor_name: str + company_id: str + company_name: str + team_members_synced: int + person_ids: List[str] + + +class SyncInvestorsResponse(BaseModel): + success: bool + synced_count: int + results: List[SyncResult] + errors: List[dict] + + +@router.get("/groups", response_model=List[GroupResponse]) +def get_folk_groups(): + """Get all groups from Folk CRM. + + Returns a list of groups with their id and name that can be used + to sync investors to Folk. + """ + try: + groups_data = folk.get_groups() + items = groups_data.get("data", {}).get("items", []) + + return [GroupResponse(id=item["id"], name=item["name"]) for item in items] + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Failed to fetch groups from Folk: {str(e)}" + ) + + +@router.post("/sync-investors", response_model=SyncInvestorsResponse) +def sync_investors_to_folk( + request: SyncInvestorsRequest, db: Session = Depends(get_db) +): + """Sync investors to Folk CRM as companies with their team members as people. + + Takes a list of investor IDs and a Folk group ID, then: + 1. Creates each investor as a company in the specified Folk group + 2. Creates each team member as a person linked to that company + + Args: + investor_ids: List of investor IDs from the database + group_id: Folk group ID where investors should be added + + Returns: + Summary of sync operation including successes and errors + """ + # Fetch investors with their team members + investors = ( + db.query(InvestorTable) + .options( + selectinload(InvestorTable.team_members), + selectinload(InvestorTable.sectors), + ) + .filter(InvestorTable.id.in_(request.investor_ids)) + .all() + ) + + if not investors: + raise HTTPException( + status_code=404, detail="No investors found with the provided IDs" + ) + + results = [] + errors = [] + + for investor in investors: + try: + # Create company in Folk + company_data = folk.create_company( + name=investor.name, + group_id=request.group_id, + website=investor.website, + description=investor.description, + addresses=[investor.headquarters] if investor.headquarters else None, + ) + + company_id = company_data.get("data", {}).get("id") + if not company_id: + errors.append( + { + "investor_id": investor.id, + "investor_name": investor.name, + "error": "No company ID returned from Folk API", + } + ) + continue + + # Create team members as people + person_ids = [] + team_members_synced = 0 + + for member in investor.team_members: + try: + # Extract first name and last name from full name + name_parts = member.name.split(maxsplit=1) + first_name = name_parts[0] if name_parts else member.name + last_name = name_parts[1] if len(name_parts) > 1 else "" + + # Build URLs list from source_url if available + urls_list = None + if hasattr(member, "source_url") and member.source_url: + urls_list = [member.source_url] + + # Build job title from title or role + job_title = None + if hasattr(member, "title") and member.title: + job_title = member.title + elif hasattr(member, "role") and member.role: + job_title = member.role + + person_data = folk.create_person( + first_name=first_name, + last_name=last_name, + email=member.email, + company_id=company_id, + group_id=request.group_id, + urls=urls_list, + jobTitle=job_title, + ) + + person_id = person_data.get("data", {}).get("id") + if person_id: + person_ids.append(person_id) + team_members_synced += 1 + except Exception as person_error: + # Log person creation error but continue with other members + errors.append( + { + "investor_id": investor.id, + "investor_name": investor.name, + "team_member_name": member.name, + "error": f"Failed to create person: {str(person_error)}", + } + ) + + results.append( + SyncResult( + investor_id=investor.id, + investor_name=investor.name, + company_id=company_id, + company_name=company_data.get("data", {}).get( + "name", investor.name + ), + team_members_synced=team_members_synced, + person_ids=person_ids, + ) + ) + + except Exception as e: + errors.append( + { + "investor_id": investor.id, + "investor_name": investor.name, + "error": str(e), + } + ) + + return SyncInvestorsResponse( + success=len(results) > 0, + synced_count=len(results), + results=results, + errors=errors, + ) diff --git a/app/routers/investors.py b/app/routers/investors.py index 816fb60..207f5a7 100644 --- a/app/routers/investors.py +++ b/app/routers/investors.py @@ -1,7 +1,7 @@ from typing import Optional from db.db import get_db -from db.models import FundTable, InvestorTable, SectorTable +from db.models import FundTable, InvestorTable, ProjectTable, SectorTable from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from schemas.router_schemas import ( @@ -12,6 +12,7 @@ from schemas.router_schemas import ( PaginatedResponse, SectorMinimal, ) +from services.compatibility_score import calculate_project_investor_compatibility from sqlalchemy.orm import Session, selectinload router = APIRouter(tags=["Investor Routes"]) @@ -46,12 +47,17 @@ class InvestorUpdate(BaseModel): def read_investors( page: int = Query(1, ge=1, description="Page number (starts at 1)"), page_size: int = Query(10, ge=1, le=100, description="Items per page (max 100)"), + project_id: Optional[int] = Query( + None, description="Optional project ID for compatibility scoring" + ), db: Session = Depends(get_db), ): """Get all investors with their funds as separate entries (paginated) Each investor-fund combination is returned as a separate row. An investor with 3 funds will appear as 3 entries. + + If project_id is provided, calculates compatibility scores for each investor. """ # Calculate offset offset = (page - 1) * page_size @@ -59,6 +65,18 @@ def read_investors( # Get total count total_count = db.query(InvestorTable).count() + # Load project if project_id provided + project = None + if project_id is not None: + 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") + # Get paginated results investors = ( db.query(InvestorTable) @@ -66,7 +84,8 @@ def read_investors( selectinload(InvestorTable.portfolio_companies), selectinload(InvestorTable.team_members), selectinload(InvestorTable.sectors), - selectinload(InvestorTable.funds), + selectinload(InvestorTable.funds).selectinload(FundTable.investment_stages), + selectinload(InvestorTable.funds).selectinload(FundTable.sectors), ) .offset(offset) .limit(page_size) @@ -76,6 +95,13 @@ def read_investors( # Transform to InvestmentResponse format (one row per investor-fund combination) investment_responses = [] for investor in investors: + # Calculate compatibility score if project provided + compatibility_score = 1.0 + if project is not None: + compatibility_score = calculate_project_investor_compatibility( + project=project, investor=investor, use_funds=True + ) + # Get top 3 portfolio companies (id and name only) portfolio_companies = [ CompanyMinimal(id=company.id, name=company.name) @@ -110,7 +136,7 @@ def read_investors( stage_focus=stage_focus, portfolio_companies=portfolio_companies, sectors=fund_sectors, - compatibility_score=1.0, + compatibility_score=compatibility_score, ) investment_responses.append(investment_response) else: @@ -125,7 +151,7 @@ def read_investors( stage_focus=None, portfolio_companies=portfolio_companies, sectors=[], - compatibility_score=1.0, + compatibility_score=compatibility_score, ) investment_responses.append(investment_response) @@ -156,14 +182,31 @@ def filter_investors( max_aum: Optional[int] = Query(None, description="Maximum AUM"), page: int = Query(1, ge=1, description="Page number (starts at 1)"), page_size: int = Query(10, ge=1, le=100, description="Items per page (max 100)"), + project_id: Optional[int] = Query( + None, description="Optional project ID for compatibility scoring" + ), db: Session = Depends(get_db), ): """Filter investors based on various criteria (paginated) Returns investor-fund combinations as separate rows. Queries the funds table to find matching funds. + + If project_id is provided, calculates compatibility scores for each investor. """ + # Load project if project_id provided + project = None + if project_id is not None: + 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") + # Start with base query on funds table query = db.query(FundTable).options( selectinload(FundTable.investor).selectinload( @@ -212,6 +255,13 @@ def filter_investors( for fund in funds: investor = fund.investor + # Calculate compatibility score if project provided + compatibility_score = 1.0 + if project is not None: + compatibility_score = calculate_project_investor_compatibility( + project=project, investor=investor, use_funds=True + ) + # Get top 3 portfolio companies (id and name only) portfolio_companies = [ CompanyMinimal(id=company.id, name=company.name) @@ -243,7 +293,7 @@ def filter_investors( stage_focus=stage_focus, portfolio_companies=portfolio_companies, sectors=fund_sectors, - compatibility_score=1.0, + compatibility_score=compatibility_score, ) investment_responses.append(investment_response) diff --git a/app/services/__pycache__/querying.cpython-312.pyc b/app/services/__pycache__/querying.cpython-312.pyc index 3159ecec8a078709de73ee9f81a4cae6f3f7970d..330306d61c33f9ca5d11833616caab77f4734159 100644 GIT binary patch delta 3333 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 float: + """ + Calculate compatibility score between a project and an investor. + + Args: + project: The project to evaluate + investor: The investor to compare against + use_funds: If True, evaluates against investor's funds. If False, uses investor-level data. + + Returns: + A score between 0 and 1, where 1 is perfect match + + Scoring breakdown (out of 100 points): + - Investment Stage Match: 30 points + - Sector Overlap: 30 points + - Geographic Match: 20 points + - Valuation/Check Size Fit: 20 points + """ + if use_funds and investor.funds: + # Calculate score for each fund and return the highest + max_score = 0.0 + for fund in investor.funds: + fund_score = _calculate_project_fund_compatibility(project, fund) + max_score = max(max_score, fund_score) + return max_score + else: + # Use investor-level data (fallback) + return _calculate_project_investor_direct_compatibility(project, investor) + + +def calculate_project_investors_compatibility( + project: ProjectTable, investors: List[InvestorTable], use_funds: bool = True +) -> List[Tuple[InvestorTable, float]]: + """ + Calculate compatibility scores between a project and multiple investors. + + Args: + project: The project to evaluate + investors: List of investors to compare against + use_funds: If True, evaluates against investors' funds. If False, uses investor-level data. + + Returns: + List of tuples (investor, score) sorted by score descending + """ + scored_investors = [] + + for investor in investors: + score = calculate_project_investor_compatibility(project, investor, use_funds) + scored_investors.append((investor, score)) + + # Sort by score descending + scored_investors.sort(key=lambda x: x[1], reverse=True) + + return scored_investors + + +def _calculate_project_fund_compatibility( + project: ProjectTable, fund: FundTable +) -> float: + """ + Calculate compatibility score between a project and a specific fund. + + Scoring breakdown: + - Investment Stage Match: 30 points (all or nothing if stage exists) + - Sector Overlap: 30 points (proportional to overlap) + - Geographic Match: 20 points (exact=20, partial=10, none=0) + - Valuation/Check Size Fit: 20 points (proportional to fit) + + Returns: + A score between 0 and 1 + """ + total_score = 0 + max_score = 100 + + # 1. Investment Stage Match (30 points) + stage_score = 0 + if project.stage and fund.investment_stages: + # Check if project stage matches any of the fund's investment stages + fund_stage_names = {stage.name for stage in fund.investment_stages} + # Convert project.stage enum to string for comparison + project_stage_name = ( + project.stage.value + if hasattr(project.stage, "value") + else str(project.stage) + ) + + if project_stage_name in fund_stage_names: + stage_score = 30 + else: + # Partial credit for adjacent stages + stage_score = _calculate_stage_proximity( + project_stage_name, fund_stage_names + ) + + total_score += stage_score + + # 2. Sector Overlap (30 points) + sector_score = 0 + if project.sector and fund.sectors: + project_sector_ids = {sector.id for sector in project.sector} + fund_sector_ids = {sector.id for sector in fund.sectors} + + if project_sector_ids and fund_sector_ids: + common_sectors = project_sector_ids.intersection(fund_sector_ids) + # Score based on what percentage of project sectors are covered by fund + overlap_ratio = len(common_sectors) / len(project_sector_ids) + sector_score = int(30 * overlap_ratio) + + total_score += sector_score + + # 3. Geographic Match (20 points) + geo_score = 0 + if project.location and fund.geographic_focus: + project_location_lower = project.location.lower() + fund_geo_lower = fund.geographic_focus.lower() + + # Exact match + if project_location_lower == fund_geo_lower: + geo_score = 20 + # Partial match (one contains the other) + elif ( + project_location_lower in fund_geo_lower + or fund_geo_lower in project_location_lower + ): + geo_score = 10 + # Check for common geographic terms + elif _check_geographic_overlap(project_location_lower, fund_geo_lower): + geo_score = 5 + + total_score += geo_score + + # 4. Valuation/Check Size Fit (20 points) + valuation_score = 0 + if project.valuation and fund.check_size_lower and fund.check_size_upper: + # Check if project valuation falls within or near the check size range + # Typically, check size is a fraction of valuation (e.g., 10-20%) + # We'll assume check size represents potential investment amount + + if fund.check_size_lower <= project.valuation <= fund.check_size_upper: + # Valuation is within the check size range (might be too small) + valuation_score = 10 + else: + # Check if the check size is reasonable for this valuation + # Typical investment is 10-30% of valuation + reasonable_valuation_min = fund.check_size_lower * 3 # Investing ~33% + reasonable_valuation_max = fund.check_size_upper * 10 # Investing ~10% + + if ( + reasonable_valuation_min + <= project.valuation + <= reasonable_valuation_max + ): + # Perfect fit + valuation_score = 20 + elif project.valuation < reasonable_valuation_min: + # Project might be too small + ratio = ( + project.valuation / reasonable_valuation_min + if reasonable_valuation_min > 0 + else 0 + ) + valuation_score = int(10 * ratio) + else: + # Project might be too large + ratio = ( + reasonable_valuation_max / project.valuation + if project.valuation > 0 + else 0 + ) + valuation_score = int(10 * ratio) + + total_score += valuation_score + + # Convert to 0-1 scale + return total_score / max_score + + +def _calculate_project_investor_direct_compatibility( + project: ProjectTable, investor: InvestorTable +) -> float: + """ + Calculate compatibility using investor-level data (fallback when no funds available). + + Uses the same scoring system but with investor-level attributes. + """ + total_score = 0 + max_score = 100 + + # 1. Investment Stage - Skip this since investors don't have a direct stage field + # We could add 30 points to other categories, but for consistency, we'll leave it as 0 + stage_score = 0 + total_score += stage_score + + # 2. Sector Overlap (30 points) + sector_score = 0 + if project.sector and investor.sectors: + project_sector_ids = {sector.id for sector in project.sector} + investor_sector_ids = {sector.id for sector in investor.sectors} + + if project_sector_ids and investor_sector_ids: + common_sectors = project_sector_ids.intersection(investor_sector_ids) + overlap_ratio = len(common_sectors) / len(project_sector_ids) + sector_score = int(30 * overlap_ratio) + + total_score += sector_score + + # 3. Geographic Match (20 points) + geo_score = 0 + if project.location and investor.geographic_focus: + project_location_lower = project.location.lower() + investor_geo_lower = investor.geographic_focus.lower() + + if project_location_lower == investor_geo_lower: + geo_score = 20 + elif ( + project_location_lower in investor_geo_lower + or investor_geo_lower in project_location_lower + ): + geo_score = 10 + elif _check_geographic_overlap(project_location_lower, investor_geo_lower): + geo_score = 5 + + total_score += geo_score + + # 4. Valuation/Check Size Fit (20 points) + valuation_score = 0 + if project.valuation and investor.check_size_lower and investor.check_size_upper: + reasonable_valuation_min = investor.check_size_lower * 3 + reasonable_valuation_max = investor.check_size_upper * 10 + + if reasonable_valuation_min <= project.valuation <= reasonable_valuation_max: + valuation_score = 20 + elif project.valuation < reasonable_valuation_min: + ratio = ( + project.valuation / reasonable_valuation_min + if reasonable_valuation_min > 0 + else 0 + ) + valuation_score = int(10 * ratio) + else: + ratio = ( + reasonable_valuation_max / project.valuation + if project.valuation > 0 + else 0 + ) + valuation_score = int(10 * ratio) + + total_score += valuation_score + + # Convert to 0-1 scale + return total_score / max_score + + +def _calculate_stage_proximity(project_stage: str, fund_stages: set) -> int: + """ + Calculate proximity score between project stage and fund stages. + Awards partial credit for adjacent investment stages. + + Stage progression: SEED -> SERIES_A -> SERIES_B -> SERIES_C -> GROWTH -> LATE_STAGE + + Returns: + Score from 0-15 (half credit for adjacent stages) + """ + stage_order = ["SEED", "SERIES_A", "SERIES_B", "SERIES_C", "GROWTH", "LATE_STAGE"] + + try: + project_idx = stage_order.index(project_stage) + except ValueError: + return 0 + + # Check for adjacent stages + adjacent_stages = [] + if project_idx > 0: + adjacent_stages.append(stage_order[project_idx - 1]) + if project_idx < len(stage_order) - 1: + adjacent_stages.append(stage_order[project_idx + 1]) + + for stage in fund_stages: + if stage in adjacent_stages: + return 15 # Half credit for adjacent stage + + return 0 + + +def _check_geographic_overlap(location1: str, location2: str) -> bool: + """ + Check for common geographic terms between two locations. + + Examples: + - "San Francisco, CA" and "California" -> True + - "New York" and "USA" -> True (if both contain USA/US) + - "London, UK" and "United Kingdom" -> True + """ + # Common geographic groupings + geo_groups = [ + ["usa", "us", "united states", "america"], + ["uk", "united kingdom", "britain"], + ["california", "ca"], + ["new york", "ny"], + ["texas", "tx"], + ["europe", "eu"], + ["asia", "asian"], + ["africa", "african"], + ] + + for group in geo_groups: + found_in_1 = any(term in location1 for term in group) + found_in_2 = any(term in location2 for term in group) + if found_in_1 and found_in_2: + return True + + return False + + +def get_top_compatible_investors( + project: ProjectTable, + investors: List[InvestorTable], + limit: int = 10, + min_score: float = 0.0, + use_funds: bool = True, +) -> List[Tuple[InvestorTable, float]]: + """ + Get the top N most compatible investors for a project. + + Args: + project: The project to find investors for + investors: List of all available investors + limit: Maximum number of investors to return + min_score: Minimum compatibility score threshold (0-1) + use_funds: If True, evaluates against investors' funds + + Returns: + List of tuples (investor, score) sorted by score descending, + limited to 'limit' items and filtered by min_score + """ + scored_investors = calculate_project_investors_compatibility( + project, investors, use_funds + ) + + # Filter by minimum score + filtered_investors = [ + (investor, score) for investor, score in scored_investors if score >= min_score + ] + + # Return top N + return filtered_investors[:limit] + + +def get_compatibility_score_breakdown( + project: ProjectTable, investor: InvestorTable, fund: Optional[FundTable] = None +) -> dict: + """ + Get a detailed breakdown of the compatibility score components. + + Useful for debugging or showing users why a particular score was calculated. + + Returns: + Dictionary with score components and explanations + """ + if fund: + total_score = 0 + + # Stage score + stage_score = 0 + stage_match = False + if project.stage and fund.investment_stages: + fund_stage_names = {stage.name for stage in fund.investment_stages} + project_stage_name = ( + project.stage.value + if hasattr(project.stage, "value") + else str(project.stage) + ) + if project_stage_name in fund_stage_names: + stage_score = 30 + stage_match = True + else: + stage_score = _calculate_stage_proximity( + project_stage_name, fund_stage_names + ) + + # Sector score + sector_score = 0 + matching_sectors = [] + if project.sector and fund.sectors: + project_sector_ids = {sector.id for sector in project.sector} + fund_sector_ids = {sector.id for sector in fund.sectors} + if project_sector_ids and fund_sector_ids: + common_sectors = project_sector_ids.intersection(fund_sector_ids) + matching_sectors = [ + s.name for s in fund.sectors if s.id in common_sectors + ] + overlap_ratio = len(common_sectors) / len(project_sector_ids) + sector_score = int(30 * overlap_ratio) + + # Geographic score + geo_score = 0 + geo_match_type = "none" + if project.location and fund.geographic_focus: + project_location_lower = project.location.lower() + fund_geo_lower = fund.geographic_focus.lower() + if project_location_lower == fund_geo_lower: + geo_score = 20 + geo_match_type = "exact" + elif ( + project_location_lower in fund_geo_lower + or fund_geo_lower in project_location_lower + ): + geo_score = 10 + geo_match_type = "partial" + elif _check_geographic_overlap(project_location_lower, fund_geo_lower): + geo_score = 5 + geo_match_type = "regional" + + # Valuation score + valuation_score = 0 + valuation_fit = "unknown" + if project.valuation and fund.check_size_lower and fund.check_size_upper: + reasonable_valuation_min = fund.check_size_lower * 3 + reasonable_valuation_max = fund.check_size_upper * 10 + if ( + reasonable_valuation_min + <= project.valuation + <= reasonable_valuation_max + ): + valuation_score = 20 + valuation_fit = "perfect" + elif project.valuation < reasonable_valuation_min: + ratio = ( + project.valuation / reasonable_valuation_min + if reasonable_valuation_min > 0 + else 0 + ) + valuation_score = int(10 * ratio) + valuation_fit = "too_small" + else: + ratio = ( + reasonable_valuation_max / project.valuation + if project.valuation > 0 + else 0 + ) + valuation_score = int(10 * ratio) + valuation_fit = "too_large" + + total_score = stage_score + sector_score + geo_score + valuation_score + + return { + "total_score": total_score / 100, + "breakdown": { + "stage": { + "score": stage_score, + "max_score": 30, + "match": stage_match, + "project_stage": project.stage.value if project.stage else None, + "fund_stages": [s.name for s in fund.investment_stages] + if fund.investment_stages + else [], + }, + "sector": { + "score": sector_score, + "max_score": 30, + "matching_sectors": matching_sectors, + "project_sectors": [s.name for s in project.sector] + if project.sector + else [], + "fund_sectors": [s.name for s in fund.sectors] + if fund.sectors + else [], + }, + "geography": { + "score": geo_score, + "max_score": 20, + "match_type": geo_match_type, + "project_location": project.location, + "fund_geography": fund.geographic_focus, + }, + "valuation": { + "score": valuation_score, + "max_score": 20, + "fit": valuation_fit, + "project_valuation": project.valuation, + "fund_check_size_range": f"{fund.check_size_lower}-{fund.check_size_upper}" + if fund.check_size_lower + else None, + }, + }, + } + else: + # Investor-level breakdown (simplified) + return { + "total_score": _calculate_project_investor_direct_compatibility( + project, investor + ), + "note": "Using investor-level data (no specific fund selected)", + } diff --git a/app/services/crm.py b/app/services/crm.py index e69de29..4801c5a 100644 --- a/app/services/crm.py +++ b/app/services/crm.py @@ -0,0 +1,260 @@ +import os +import sys + +import requests + + +class FolkAPI: + BASE_URL = "https://api.folk.app/v1" + + def __init__(self, api_key: str): + self.headers = {"Authorization": f"Bearer {api_key}"} + + def get_groups(self): + """Fetch all groups from Folk.""" + url = f"{self.BASE_URL}/groups" + response = requests.get(url, headers=self.headers) + response.raise_for_status() + return response.json() + + def create_company( + self, + name: str, + group_id: str = None, + website: str = None, + linkedin_url: str = None, + description: str = None, + emails=None, + phones=None, + addresses=None, + urls=None, + custom_field_values=None, + groups=None, + **kwargs, + ): + """Create a company (investor) in a specific group. + + This method builds a payload matching Folk's Create Company API: + https://developer.folk.app/api-reference/companies/create-a-company + + It keeps backward compatibility with the previous `group_id`, + `website` and `linkedin_url` arguments. + """ + url = f"{self.BASE_URL}/companies" + + # Build the top-level payload expected by Folk + data = {"name": name} + if description: + data["description"] = description + + # Groups: prefer explicit `groups`, else fall back to `group_id` + if groups: + # Accept either list of ids or list of dicts + formatted = [] + for g in groups: + if isinstance(g, dict) and g.get("id"): + formatted.append({"id": g["id"]}) + else: + formatted.append({"id": str(g)}) + data["groups"] = formatted + elif group_id: + data["groups"] = [{"id": group_id}] + + # Helper to normalize single or multiple inputs into lists + def _to_list(val): + if val is None: + return None + if isinstance(val, (list, tuple)): + return [v for v in val if v is not None] + return [val] + + # URLs: include website and linkedin_url if provided and merge with urls + urls_list = _to_list(urls) or [] + if website: + urls_list.append(website) + if linkedin_url: + urls_list.append(linkedin_url) + if urls_list: + data["urls"] = urls_list + + # Emails/phones/addresses + emails_list = _to_list(emails) + if emails_list: + data["emails"] = emails_list + phones_list = _to_list(phones) + if phones_list: + data["phones"] = phones_list + addresses_list = _to_list(addresses) + if addresses_list: + data["addresses"] = addresses_list + + # Custom field values follow the API's structure + if custom_field_values: + data["customFieldValues"] = custom_field_values + + # Allow passing any additional top-level fields via kwargs (careful) + for k, v in kwargs.items(): + # don't overwrite keys we explicitly set + if k not in data: + data[k] = v + + response = requests.post(url, headers=self.headers, json=data) + response.raise_for_status() + return response.json() + + def create_person( + self, + first_name: str, + last_name: str, + email: str = None, + company_id: str = None, + group_id: str = None, + companies=None, + emails=None, + phones=None, + addresses=None, + urls=None, + custom_field_values=None, + groups=None, + **kwargs, + ): + """Create a person in the workspace. + + Builds payload matching Folk's Create Person API: use camelCase + keys (firstName, lastName, groups, companies, emails, etc.). + Keeps backward compatibility with `company_id` and `group_id`. + """ + url = f"{self.BASE_URL}/people" + + data = {"firstName": first_name, "lastName": last_name} + + # Groups: explicit `groups` preferred, else fallback to `group_id` + if groups: + formatted = [] + for g in groups: + if isinstance(g, dict) and g.get("id"): + formatted.append({"id": g["id"]}) + else: + formatted.append({"id": str(g)}) + data["groups"] = formatted + elif group_id: + data["groups"] = [{"id": group_id}] + + # Companies: keep backward compatibility with company_id + if companies: + formatted = [] + for c in companies: + if isinstance(c, dict): + formatted.append(c) + elif isinstance(c, str): + # treat as id + formatted.append({"id": c}) + if formatted: + data["companies"] = formatted + elif company_id: + data["companies"] = [{"id": company_id}] + + # Helper to normalize into lists + def _to_list(val): + if val is None: + return None + if isinstance(val, (list, tuple)): + return [v for v in val if v is not None] + return [val] + + emails_list = _to_list(emails) or [] + if email: + emails_list.insert(0, email) + if emails_list: + data["emails"] = emails_list + + phones_list = _to_list(phones) + if phones_list: + data["phones"] = phones_list + addresses_list = _to_list(addresses) + if addresses_list: + data["addresses"] = addresses_list + urls_list = _to_list(urls) + if urls_list: + data["urls"] = urls_list + + if custom_field_values: + data["customFieldValues"] = custom_field_values + + # Allow passthrough of other top-level fields in kwargs + for k, v in kwargs.items(): + if k not in data: + data[k] = v + + response = requests.post(url, headers=self.headers, json=data) + response.raise_for_status() + return response.json() + + +# Prefer getting the API key from the environment. If not set, fall back to the +# existing (hard-coded) key so behavior is unchanged for now. +DEFAULT_API_KEY = "FOLKfIGXuv74ML9EAajxyiUR39ePaNrZ" +api_key = os.environ.get("FOLK_API_KEY", DEFAULT_API_KEY) + +folk = FolkAPI(api_key=api_key) + + +def example_flow(): + # Step 1: Get groups + groups = folk.get_groups() + print(groups) + + # Safely dig into the returned structure. The API returns groups under + # groups['data']['items'] (not groups['data'][0]). Handle missing/empty. + items = groups.get("data", {}).get("items", []) + if not items: + print("No groups returned by Folk API.") + sys.exit(1) + + # Choose the first group as an example + group_id = items[0].get("id") + if not group_id: + print("No id found for the first group item.") + sys.exit(1) + + # Step 2: Choose a group_id and create a company + company = folk.create_company( + name="2050 Investment Partners", + group_id=group_id, + website="https://2050.com", + linkedin_url="https://linkedin.com/company/2050-investments", + ) + + # Step 3: Add a person to the same group or company + person = folk.create_person( + first_name="John", + last_name="Doe", + email="john@2050.com", + company_id=company.get("data", {}).get("id"), + group_id=group_id, + ) + + print("Created company:", company) + print("Created person:", person) + + +if __name__ == "__main__": + try: + example_flow() + except requests.HTTPError as e: + # Try to include response body for easier debugging if available + resp = getattr(e, "response", None) + if resp is not None: + try: + body = resp.text + except Exception: + body = "" + print("HTTP error while talking to Folk API:", e) + print("Response status:", resp.status_code) + print("Response body:", body) + else: + print("HTTP error while talking to Folk API:", e) + sys.exit(1) + except Exception as e: # pragma: no cover - top-level safety + print("Unexpected error:", e) + sys.exit(1) diff --git a/app/services/querying.py b/app/services/querying.py index 05b3fae..98d109f 100644 --- a/app/services/querying.py +++ b/app/services/querying.py @@ -1,8 +1,8 @@ import os -from typing import List +from typing import List, Optional from db.db import DATABASE_URL, get_db -from db.models import FundTable, InvestorTable +from db.models import FundTable, InvestorTable, ProjectTable from langchain import hub from langchain_community.agent_toolkits import SQLDatabaseToolkit from langchain_community.utilities import SQLDatabase @@ -16,6 +16,8 @@ from schemas.router_schemas import ( ) from sqlalchemy.orm import selectinload +from services.compatibility_score import calculate_project_investor_compatibility + # Connect to SQLite prompt_template = hub.pull("langchain-ai/sql-agent-system-prompt") db = SQLDatabase.from_uri(DATABASE_URL) @@ -44,8 +46,15 @@ class QueryProcessor: prompt=system_message_updated, ) - def process_query(self, question: str) -> PaginatedResponse[InvestmentResponse]: - """Process a query using the LLM and return investment response data.""" + def process_query( + self, question: str, project_id: Optional[int] = None + ) -> PaginatedResponse[InvestmentResponse]: + """Process a query using the LLM and return investment response data. + + Args: + question: The natural language query to process + project_id: Optional project ID for compatibility scoring + """ # Let the LLM handle all database interactions and filtering to get fund IDs response = self.agent.invoke( {"messages": [("user", question)]}, @@ -60,7 +69,7 @@ class QueryProcessor: fund_ids = self._extract_fund_ids_from_response(ai_response) # Fetch full fund data with investor relationships using the IDs - return self._fetch_funds_by_ids(fund_ids) + return self._fetch_funds_by_ids(fund_ids, project_id) def _extract_fund_ids_from_response(self, ai_response: str) -> List[int]: """Extract fund IDs from AI response.""" @@ -85,10 +94,15 @@ class QueryProcessor: return fund_ids def _fetch_funds_by_ids( - self, fund_ids: List[int] + self, fund_ids: List[int], project_id: Optional[int] = None ) -> PaginatedResponse[InvestmentResponse]: """Fetch funds with all their relationships from the database using fund IDs. - Constructs response similar to read_investors but starting from funds.""" + Constructs response similar to read_investors but starting from funds. + + Args: + fund_ids: List of fund IDs to fetch + project_id: Optional project ID for compatibility scoring + """ if not fund_ids: return PaginatedResponse( items=[], @@ -102,6 +116,16 @@ class QueryProcessor: db_session = next(get_db()) try: + # Load project if project_id provided + project = None + if project_id is not None: + project = ( + db_session.query(ProjectTable) + .options(selectinload(ProjectTable.sector)) + .filter(ProjectTable.id == project_id) + .first() + ) + # Query funds with all necessary relationships loaded funds = ( db_session.query(FundTable) @@ -127,6 +151,13 @@ class QueryProcessor: for fund in funds: investor = fund.investor + # Calculate compatibility score if project provided + compatibility_score = 1.0 + if project is not None: + compatibility_score = calculate_project_investor_compatibility( + project=project, investor=investor, use_funds=True + ) + # Get top 3 portfolio companies (id and name only) portfolio_companies = [ CompanyMinimal(id=company.id, name=company.name) @@ -158,7 +189,7 @@ class QueryProcessor: stage_focus=stage_focus, portfolio_companies=portfolio_companies, sectors=fund_sectors, - compatibility_score=1.0, + compatibility_score=compatibility_score, ) investment_responses.append(investment_response)