From 37e1ad01c4973cd80ec70714c67e4a6481957ea1 Mon Sep 17 00:00:00 2001 From: bolade Date: Wed, 8 Oct 2025 11:48:26 +0100 Subject: [PATCH] feat: Update investor and fund schemas for streamlined investment responses --- .../__pycache__/companies.cpython-312.pyc | Bin 9792 -> 11046 bytes .../__pycache__/investors.cpython-312.pyc | Bin 21071 -> 21921 bytes app/routers/investors.py | 493 ++++++++---------- .../router_schemas.cpython-312.pyc | Bin 7870 -> 10896 bytes app/schemas/router_schemas.py | 68 ++- 5 files changed, 284 insertions(+), 277 deletions(-) diff --git a/app/routers/__pycache__/companies.cpython-312.pyc b/app/routers/__pycache__/companies.cpython-312.pyc index d1b45589eb3180d359ee8fe271d25b168be031c6..ab5935069f732fe0476d9cb5dbe4f1ccc5d2cf80 100644 GIT binary patch delta 3613 zcmbVOYfKy26}~g$8GHO3zW^J94d#)7fPtpjB#=$A$-=%u9z^NNN|u<+Kul~*uLm`t zK~AKtx+p6p3QG8GeyKc!k|8xPp4bw0bxLhP z-P?hPQny_HluHpM`iv&_jHclkP2)3~_%2Q1>qaXoO)8^?Y3`KfZLS5l8dKN0&FurO z*3`+{cH5Z}AXPr4jYw=s8D;-gVl7Gsxx_ZN{y*DJHE0&Wq9n*78}In? zl{?j{t+I8If3oqu_DWfy^w0C7dsl(u(-FDn5iPCEkjq@Qfi)Z_bJ@w%#Pq3@mNL9& zQ)B6gl%^`@)%;{`BCoD&a4$0qw^Gl)&p7Du#1TsB$h=TP{w#b@ctj7@Ob8~bv5Bed zh>BzT@>&XO`B+Meb;~kquwyE&0;@+9XpaC6m|&@893_guZC zR9i>vwjjfjn5`G+C0i4@6B0Sg9>Wf{lGY`bbV;?0m;A(*06OSyA*%rqxPJrJ74$M8 z@+cA^55%B@smvL#BOh0XW3&7iniX{Z6uNvI+^}jI2L*Wg6oL?sD%{CCgIU0aZguK3OV zk`0NDJGMF7dyzF;{if)dt9sA5E;SUThIOg6D77wH)})SQeLXQyObo0iP8SoWf2FJ? zhStOjo1%Lzc)Pk}MZ5&-?zv9WS6PTSYkMPih1;+{toY9Kni$#?opb)%!BPi*!Cpx#K0QuO{B8obX&*-K0nUG~7N z6OioEom)tDYaQFBZrwd@t4KS&?k1lHgN*y0H%J^+;>fH=ckT7C>YmD0TZp#nRuj>c zASj|GjpD*=x=V);;r@`8`CW(>W)6};Z(ILv`2LG&9yB40J7FsR1`s)fU!XjNC+?+W z59r}TWYHTf9EM?oOXr6T%Xlt5fdy(#p^T4ELUD$VQt~1t6q)!KB_=Grn0`0mASEwT z(oe|%C9hDjeSefeYdUqD4w%cH0InXtL}dzGe3B9quU_EFc*N(vIKbT zTp9CSVfyUIUvs~GzMXX3e7z(*jo}R`c;7kiT)4O+?gqax9<$)&8sXyep40Y-vs%GzJUo{}LT7i7k8sW+}>rWAE}3O)ri^C-#QT`?Y_#yhKMFyf#W|)jcTfAeScnJS|w0uF^NulxR%I~5q>)7Qv`J$oY z(9SvE!$U(*)Qk#m6LssUoWDg*H#P{@K`k&uYt&yecx9wh8NqM~B0~f|n}fOeGK455 z4C`nLzIGh^Og5VO2iz)83*ya5KEdyz)>A-c2o>SZ(kBL>l-s7+&ak(6Gp@9v=E6 zc;QoDXrYB9{$}lAgbll^#6kbL$5-N^2Ox>k-|5I>4zaH?C6+438N}MY7rqr>8xooOxIu->^AX!!JJJ$epG)3f`(O-8Fuefo5|tJeaIJHXqvAt`AxB=VjKdoODNSNhr1Lthhj+bml{mA Vkw#_iOv5@0VVUYt@@DfF{{=xPC4B$@ delta 2490 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 diff --git a/app/routers/investors.py b/app/routers/investors.py index c415aaa..816fb60 100644 --- a/app/routers/investors.py +++ b/app/routers/investors.py @@ -1,14 +1,16 @@ from typing import Optional from db.db import get_db -from db.models import InvestorTable, SectorTable +from db.models import FundTable, InvestorTable, SectorTable from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from schemas.router_schemas import ( + CompanyMinimal, + InvestmentResponse, InvestmentStage, InvestorData, - InvestorFundData, PaginatedResponse, + SectorMinimal, ) from sqlalchemy.orm import Session, selectinload @@ -40,7 +42,7 @@ class InvestorUpdate(BaseModel): number_of_investments: Optional[int] = None -@router.get("/investors", response_model=PaginatedResponse[InvestorFundData]) +@router.get("/investors", response_model=PaginatedResponse[InvestmentResponse]) 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)"), @@ -71,78 +73,67 @@ def read_investors( .all() ) - # Transform to InvestorFundData format (one row per investor-fund combination) - investor_fund_list = [] + # Transform to InvestmentResponse format (one row per investor-fund combination) + investment_responses = [] for investor in investors: + # Get top 3 portfolio companies (id and name only) + portfolio_companies = [ + CompanyMinimal(id=company.id, name=company.name) + for company in investor.portfolio_companies[:3] + ] + # If investor has funds, create one entry per fund if investor.funds: for fund in investor.funds: - investor_fund_data = InvestorFundData( - # Investor fields - investor_id=investor.id, - investor_name=investor.name, - investor_description=investor.description, - investor_website=investor.website, - investor_headquarters=investor.headquarters, + # Get stage focus as comma-separated string + stage_focus = ( + ", ".join([stage.name for stage in fund.investment_stages]) + if fund.investment_stages + else None + ) + + # Get top 3 sectors from fund (id and name only) + fund_sectors = [ + SectorMinimal(id=sector.id, name=sector.name) + for sector in (fund.sectors[:3] if fund.sectors else []) + ] + + investment_response = InvestmentResponse( + id=investor.id, + name=f"{investor.name} - {fund.fund_name}" + if fund.fund_name + else investor.name, aum=investor.aum, - aum_as_of_date=investor.aum_as_of_date, - aum_source_url=investor.aum_source_url, - investment_thesis=investor.investment_thesis, - portfolio_highlights=investor.portfolio_highlights, - number_of_investments=investor.number_of_investments, - # Fund fields - fund_id=fund.id, - fund_name=fund.fund_name, - fund_size=fund.fund_size, - fund_size_source_url=fund.fund_size_source_url, check_size_lower=fund.check_size_lower, check_size_upper=fund.check_size_upper, geographic_focus=fund.geographic_focus, - fund_investment_stages=fund.investment_stages, # Now a relationship - fund_sectors=fund.sectors, # Now a relationship - # Related data (same for all funds of this investor) - portfolio_companies=investor.portfolio_companies, - team_members=investor.team_members, - sectors=investor.sectors, + stage_focus=stage_focus, + portfolio_companies=portfolio_companies, + sectors=fund_sectors, + compatibility_score=1.0, ) - investor_fund_list.append(investor_fund_data) + investment_responses.append(investment_response) else: # If no funds, create one entry with null fund fields - investor_fund_data = InvestorFundData( - # Investor fields - investor_id=investor.id, - investor_name=investor.name, - investor_description=investor.description, - investor_website=investor.website, - investor_headquarters=investor.headquarters, + investment_response = InvestmentResponse( + id=investor.id, + name=investor.name, aum=investor.aum, - aum_as_of_date=investor.aum_as_of_date, - aum_source_url=investor.aum_source_url, - investment_thesis=investor.investment_thesis, - portfolio_highlights=investor.portfolio_highlights, - number_of_investments=investor.number_of_investments, - # Fund fields (null) - fund_id=None, - fund_name=None, - fund_size=None, - fund_size_source_url=None, check_size_lower=None, check_size_upper=None, geographic_focus=None, - fund_investment_stages=None, - fund_sectors=None, - # Related data - portfolio_companies=investor.portfolio_companies, - team_members=investor.team_members, - sectors=investor.sectors, + stage_focus=None, + portfolio_companies=portfolio_companies, + sectors=[], + compatibility_score=1.0, ) - investor_fund_list.append(investor_fund_data) + investment_responses.append(investment_response) # Calculate total pages total_pages = (total_count + page_size - 1) // page_size return PaginatedResponse( - items=investor_fund_list, + items=investment_responses, total=total_count, page=page, page_size=page_size, @@ -150,7 +141,7 @@ def read_investors( ) -@router.get("/investors/filter", response_model=PaginatedResponse[InvestorFundData]) +@router.get("/investors/filter", response_model=PaginatedResponse[InvestmentResponse]) def filter_investors( stage: Optional[InvestmentStage] = Query( None, description="Filter by investment stage" @@ -170,40 +161,42 @@ def filter_investors( """Filter investors based on various criteria (paginated) Returns investor-fund combinations as separate rows. - An investor with 3 funds will appear as 3 entries. + Queries the funds table to find matching funds. """ - # Start with base query - query = db.query(InvestorTable).options( - selectinload(InvestorTable.portfolio_companies), - selectinload(InvestorTable.team_members), - selectinload(InvestorTable.sectors), - selectinload(InvestorTable.funds), + # Start with base query on funds table + query = db.query(FundTable).options( + selectinload(FundTable.investor).selectinload( + InvestorTable.portfolio_companies + ), + selectinload(FundTable.investor).selectinload(InvestorTable.team_members), + selectinload(FundTable.investor).selectinload(InvestorTable.sectors), + selectinload(FundTable.investment_stages), + selectinload(FundTable.sectors), ) - # Apply filters - # Note: stage filtering is now done at fund level via fund.investment_stages - # if stage: - # query = query.filter(InvestorTable.stage_focus == stage) - + # Apply filters at fund level if min_check_size is not None: - query = query.filter(InvestorTable.check_size_lower >= min_check_size) + query = query.filter(FundTable.check_size_lower >= min_check_size) if max_check_size is not None: - query = query.filter(InvestorTable.check_size_upper <= max_check_size) + query = query.filter(FundTable.check_size_upper <= max_check_size) if geography: - query = query.filter(InvestorTable.geographic_focus.ilike(f"%{geography}%")) + query = query.filter(FundTable.geographic_focus.ilike(f"%{geography}%")) + # Apply filters at investor level (through relationship) if min_aum is not None: - query = query.filter(InvestorTable.aum >= min_aum) + query = query.join(FundTable.investor).filter(InvestorTable.aum >= min_aum) if max_aum is not None: + if min_aum is None: # Only join if not already joined + query = query.join(FundTable.investor) query = query.filter(InvestorTable.aum <= max_aum) - # Filter by sector if provided + # Filter by sector if provided (at fund level) if sector: - query = query.join(InvestorTable.sectors).filter( + query = query.join(FundTable.sectors).filter( SectorTable.name.ilike(f"%{sector}%") ) @@ -212,80 +205,53 @@ def filter_investors( # Calculate offset and apply pagination offset = (page - 1) * page_size - investors = query.offset(offset).limit(page_size).all() + funds = query.offset(offset).limit(page_size).all() - # Transform to InvestorFundData format (one row per investor-fund combination) - investor_fund_list = [] - for investor in investors: - # If investor has funds, create one entry per fund - if investor.funds: - for fund in investor.funds: - investor_fund_data = InvestorFundData( - # Investor fields - investor_id=investor.id, - investor_name=investor.name, - investor_description=investor.description, - investor_website=investor.website, - investor_headquarters=investor.headquarters, - aum=investor.aum, - aum_as_of_date=investor.aum_as_of_date, - aum_source_url=investor.aum_source_url, - investment_thesis=investor.investment_thesis, - portfolio_highlights=investor.portfolio_highlights, - number_of_investments=investor.number_of_investments, - # Fund fields - fund_id=fund.id, - fund_name=fund.fund_name, - fund_size=fund.fund_size, - fund_size_source_url=fund.fund_size_source_url, - check_size_lower=fund.check_size_lower, - check_size_upper=fund.check_size_upper, - geographic_focus=fund.geographic_focus, - fund_investment_stages=fund.investment_stages, # Now a relationship - fund_sectors=fund.sectors, # Now a relationship - # Related data - portfolio_companies=investor.portfolio_companies, - team_members=investor.team_members, - sectors=investor.sectors, - ) - investor_fund_list.append(investor_fund_data) - else: - # If no funds, create one entry with null fund fields - investor_fund_data = InvestorFundData( - # Investor fields - investor_id=investor.id, - investor_name=investor.name, - investor_description=investor.description, - investor_website=investor.website, - investor_headquarters=investor.headquarters, - aum=investor.aum, - aum_as_of_date=investor.aum_as_of_date, - aum_source_url=investor.aum_source_url, - investment_thesis=investor.investment_thesis, - portfolio_highlights=investor.portfolio_highlights, - number_of_investments=investor.number_of_investments, - # Fund fields (null) - fund_id=None, - fund_name=None, - fund_size=None, - fund_size_source_url=None, - check_size_lower=None, - check_size_upper=None, - geographic_focus=None, - fund_investment_stages=None, - fund_sectors=None, - # Related data - portfolio_companies=investor.portfolio_companies, - team_members=investor.team_members, - sectors=investor.sectors, - ) - investor_fund_list.append(investor_fund_data) + # Transform to InvestmentResponse format (one row per fund) + investment_responses = [] + for fund in funds: + investor = fund.investor + + # Get top 3 portfolio companies (id and name only) + portfolio_companies = [ + CompanyMinimal(id=company.id, name=company.name) + for company in investor.portfolio_companies[:3] + ] + + # Get stage focus as comma-separated string + stage_focus = ( + ", ".join([stage.name for stage in fund.investment_stages]) + if fund.investment_stages + else None + ) + + # Get top 3 sectors from fund (id and name only) + fund_sectors = [ + SectorMinimal(id=sector.id, name=sector.name) + for sector in (fund.sectors[:3] if fund.sectors else []) + ] + + investment_response = InvestmentResponse( + id=investor.id, + name=f"{investor.name} - {fund.fund_name}" + if fund.fund_name + else investor.name, + aum=investor.aum, + check_size_lower=fund.check_size_lower, + check_size_upper=fund.check_size_upper, + geographic_focus=fund.geographic_focus, + stage_focus=stage_focus, + portfolio_companies=portfolio_companies, + sectors=fund_sectors, + compatibility_score=1.0, + ) + investment_responses.append(investment_response) # Calculate total pages total_pages = (total_count + page_size - 1) // page_size return PaginatedResponse( - items=investor_fund_list, + items=investment_responses, total=total_count, page=page, page_size=page_size, @@ -409,7 +375,7 @@ def delete_investor(investor_id: int, db: Session = Depends(get_db)): @router.get( "/investors/{investor_id}/similar", - response_model=PaginatedResponse[InvestorFundData], + response_model=PaginatedResponse[InvestmentResponse], ) def find_similar_investors( investor_id: int, @@ -421,16 +387,18 @@ def find_similar_investors( """Find investors similar to a given investor based on characteristics (paginated) Returns investor-fund combinations as separate rows. + Queries the funds table to find matching funds. """ - # Get the target investor + # Get the target investor to get their funds for comparison target_investor = ( db.query(InvestorTable) .options( 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), ) .filter(InvestorTable.id == investor_id) .first() @@ -439,168 +407,147 @@ def find_similar_investors( if not target_investor: raise HTTPException(status_code=404, detail="Investor not found") - # Get target investor's sector IDs for comparison - target_sector_ids = {sector.id for sector in target_investor.sectors} + # Get target investor's sector IDs for comparison (from their funds) + target_sector_ids = set() + target_stage_ids = set() + target_check_ranges = [] + target_geographies = [] - # Query all other investors with their relationships - candidates = ( - db.query(InvestorTable) + for fund in target_investor.funds: + if fund.sectors: + target_sector_ids.update({sector.id for sector in fund.sectors}) + if fund.investment_stages: + target_stage_ids.update({stage.id for stage in fund.investment_stages}) + if fund.check_size_lower and fund.check_size_upper: + target_check_ranges.append((fund.check_size_lower, fund.check_size_upper)) + if fund.geographic_focus: + target_geographies.append(fund.geographic_focus.lower()) + + # Query all funds from other investors + candidate_funds = ( + db.query(FundTable) .options( - selectinload(InvestorTable.portfolio_companies), - selectinload(InvestorTable.team_members), - selectinload(InvestorTable.sectors), - selectinload(InvestorTable.funds), + selectinload(FundTable.investor).selectinload( + InvestorTable.portfolio_companies + ), + selectinload(FundTable.investor).selectinload(InvestorTable.team_members), + selectinload(FundTable.investor).selectinload(InvestorTable.sectors), + selectinload(FundTable.investment_stages), + selectinload(FundTable.sectors), ) + .join(FundTable.investor) .filter(InvestorTable.id != investor_id) .all() ) - # Calculate similarity scores - scored_investors = [] - for candidate in candidates: + # Calculate similarity scores for each fund + scored_funds = [] + for fund in candidate_funds: score = 0 - # Stage focus match is now handled at fund level - # Skip stage matching at investor level since stage_focus no longer exists - # if candidate.stage_focus == target_investor.stage_focus: - # score += 30 - # Geographic focus match (20 points for exact, 10 for partial) - if candidate.geographic_focus and target_investor.geographic_focus: - if ( - candidate.geographic_focus.lower() - == target_investor.geographic_focus.lower() - ): - score += 20 - elif ( - candidate.geographic_focus.lower() - in target_investor.geographic_focus.lower() - or target_investor.geographic_focus.lower() - in candidate.geographic_focus.lower() - ): - score += 10 + if fund.geographic_focus and target_geographies: + fund_geo_lower = fund.geographic_focus.lower() + for target_geo in target_geographies: + if fund_geo_lower == target_geo: + score += 20 + break + elif fund_geo_lower in target_geo or target_geo in fund_geo_lower: + score += 10 + break # Check size overlap (20 points max) - if ( - candidate.check_size_lower - and candidate.check_size_upper - and target_investor.check_size_lower - and target_investor.check_size_upper - ): - # Calculate overlap percentage - overlap_start = max( - candidate.check_size_lower, target_investor.check_size_lower - ) - overlap_end = min( - candidate.check_size_upper, target_investor.check_size_upper - ) - if overlap_end > overlap_start: - overlap = overlap_end - overlap_start - target_range = ( - target_investor.check_size_upper - target_investor.check_size_lower - ) - overlap_ratio = overlap / target_range if target_range > 0 else 0 - score += int(20 * overlap_ratio) + if fund.check_size_lower and fund.check_size_upper and target_check_ranges: + max_overlap_score = 0 + for target_lower, target_upper in target_check_ranges: + overlap_start = max(fund.check_size_lower, target_lower) + overlap_end = min(fund.check_size_upper, target_upper) + if overlap_end > overlap_start: + overlap = overlap_end - overlap_start + target_range = target_upper - target_lower + overlap_ratio = overlap / target_range if target_range > 0 else 0 + max_overlap_score = max(max_overlap_score, int(20 * overlap_ratio)) + score += max_overlap_score # AUM similarity (15 points max) - if candidate.aum and target_investor.aum: - aum_diff = abs(candidate.aum - target_investor.aum) - max_aum = max(candidate.aum, target_investor.aum) + if fund.investor.aum and target_investor.aum: + aum_diff = abs(fund.investor.aum - target_investor.aum) + max_aum = max(fund.investor.aum, target_investor.aum) similarity_ratio = 1 - (aum_diff / max_aum) if max_aum > 0 else 0 score += int(15 * similarity_ratio) # Sector overlap (30 points max) - candidate_sector_ids = {sector.id for sector in candidate.sectors} - if target_sector_ids and candidate_sector_ids: - common_sectors = target_sector_ids.intersection(candidate_sector_ids) + if fund.sectors and target_sector_ids: + fund_sector_ids = {sector.id for sector in fund.sectors} + common_sectors = target_sector_ids.intersection(fund_sector_ids) overlap_ratio = len(common_sectors) / len(target_sector_ids) score += int(30 * overlap_ratio) - if score > 0: # Only include investors with some similarity - scored_investors.append((score, candidate)) + # Investment stage match (15 points max) + if fund.investment_stages and target_stage_ids: + fund_stage_ids = {stage.id for stage in fund.investment_stages} + common_stages = target_stage_ids.intersection(fund_stage_ids) + overlap_ratio = len(common_stages) / len(target_stage_ids) + score += int(15 * overlap_ratio) + + if score > 0: # Only include funds with some similarity + scored_funds.append((score, fund)) # Sort by score (descending) and take top N based on limit - scored_investors.sort(key=lambda x: x[0], reverse=True) - top_similar = scored_investors[:limit] + scored_funds.sort(key=lambda x: x[0], reverse=True) + top_similar = scored_funds[:limit] - # Apply pagination to the top similar investors + # Apply pagination to the top similar funds total_count = len(top_similar) offset = (page - 1) * page_size paginated_similar = top_similar[offset : offset + page_size] - similar_investors = [inv for score, inv in paginated_similar] + similar_funds = [fund for score, fund in paginated_similar] - # Transform to InvestorFundData format (one row per investor-fund combination) - investor_fund_list = [] - for investor in similar_investors: - # If investor has funds, create one entry per fund - if investor.funds: - for fund in investor.funds: - investor_fund_data = InvestorFundData( - # Investor fields - investor_id=investor.id, - investor_name=investor.name, - investor_description=investor.description, - investor_website=investor.website, - investor_headquarters=investor.headquarters, - aum=investor.aum, - aum_as_of_date=investor.aum_as_of_date, - aum_source_url=investor.aum_source_url, - investment_thesis=investor.investment_thesis, - portfolio_highlights=investor.portfolio_highlights, - number_of_investments=investor.number_of_investments, - # Fund fields - fund_id=fund.id, - fund_name=fund.fund_name, - fund_size=fund.fund_size, - fund_size_source_url=fund.fund_size_source_url, - check_size_lower=fund.check_size_lower, - check_size_upper=fund.check_size_upper, - geographic_focus=fund.geographic_focus, - fund_investment_stages=fund.investment_stages, # Now a relationship - fund_sectors=fund.sectors, # Now a relationship - # Related data - portfolio_companies=investor.portfolio_companies, - team_members=investor.team_members, - sectors=investor.sectors, - ) - investor_fund_list.append(investor_fund_data) - else: - # If no funds, create one entry with null fund fields - investor_fund_data = InvestorFundData( - # Investor fields - investor_id=investor.id, - investor_name=investor.name, - investor_description=investor.description, - investor_website=investor.website, - investor_headquarters=investor.headquarters, - aum=investor.aum, - aum_as_of_date=investor.aum_as_of_date, - aum_source_url=investor.aum_source_url, - investment_thesis=investor.investment_thesis, - portfolio_highlights=investor.portfolio_highlights, - number_of_investments=investor.number_of_investments, - # Fund fields (null) - fund_id=None, - fund_name=None, - fund_size=None, - fund_size_source_url=None, - check_size_lower=None, - check_size_upper=None, - geographic_focus=None, - fund_investment_stages=None, - fund_sectors=None, - # Related data - portfolio_companies=investor.portfolio_companies, - team_members=investor.team_members, - sectors=investor.sectors, - ) - investor_fund_list.append(investor_fund_data) + # Transform to InvestmentResponse format (one row per fund) + investment_responses = [] + for fund in similar_funds: + investor = fund.investor + + # Get top 3 portfolio companies (id and name only) + portfolio_companies = [ + CompanyMinimal(id=company.id, name=company.name) + for company in investor.portfolio_companies[:3] + ] + + # Get stage focus as comma-separated string + stage_focus = ( + ", ".join([stage.name for stage in fund.investment_stages]) + if fund.investment_stages + else None + ) + + # Get top 3 sectors from fund (id and name only) + fund_sectors = [ + SectorMinimal(id=sector.id, name=sector.name) + for sector in (fund.sectors[:3] if fund.sectors else []) + ] + + investment_response = InvestmentResponse( + id=investor.id, + name=f"{investor.name} - {fund.fund_name}" + if fund.fund_name + else investor.name, + aum=investor.aum, + check_size_lower=fund.check_size_lower, + check_size_upper=fund.check_size_upper, + geographic_focus=fund.geographic_focus, + stage_focus=stage_focus, + portfolio_companies=portfolio_companies, + sectors=fund_sectors, + compatibility_score=1.0, + ) + investment_responses.append(investment_response) # Calculate total pages total_pages = (total_count + page_size - 1) // page_size return PaginatedResponse( - items=investor_fund_list, + items=investment_responses, total=total_count, page=page, page_size=page_size, diff --git a/app/schemas/__pycache__/router_schemas.cpython-312.pyc b/app/schemas/__pycache__/router_schemas.cpython-312.pyc index 69fb8c45af7a50bb4b44982f5d1e2bc81eeebe70..d58670d72bcb76bf54468bfb9c78954c2c721f87 100644 GIT binary patch literal 10896 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 diff --git a/app/schemas/router_schemas.py b/app/schemas/router_schemas.py index 8379d3c..10eee8c 100644 --- a/app/schemas/router_schemas.py +++ b/app/schemas/router_schemas.py @@ -168,12 +168,29 @@ class InvestorFundData(BaseModel): class Config: from_attributes = True +class InvestorMinimal(BaseModel): + """Minimal investor info with just id and name""" + + id: int + name: str + + class Config: + from_attributes = True + +class CompanySchemaMinimal(BaseModel): + id: int + name: str + industry: str | None + location: str | None + founded_year: Optional[int] + website: Optional[str] + + class Config: + from_attributes = True class CompanyData(BaseModel): # Renamed from CompaniesData for consistency - company: CompanySchema - sectors: List[SectorSchema] - members: List[CompanyMemberSchema] - investors: List[InvestorSchema] + company: CompanySchemaMinimal + investors: List[InvestorMinimal] class Config: from_attributes = True @@ -189,6 +206,49 @@ class InvestorFundList(BaseModel): investor_funds: List[InvestorFundData] +class CompanyMinimal(BaseModel): + """Minimal company info with just id and name""" + + id: int + name: str + + class Config: + from_attributes = True + + +class SectorMinimal(BaseModel): + """Minimal sector info with just id and name""" + + id: int + name: str + + class Config: + from_attributes = True + + +class InvestmentResponse(BaseModel): + """Simplified investment response schema + + One row per investor-fund combination with streamlined data + """ + + id: int # Investor ID + name: ( + str # Combination of investor name and fund name (e.g., "Investor A - Fund A") + ) + aum: int | None # From investor + check_size_lower: int | None # From fund + check_size_upper: int | None # From fund + geographic_focus: str | None # From fund + stage_focus: str | None # Comma-separated stages from fund + portfolio_companies: List[CompanyMinimal] # Top 3 companies from investor + sectors: List[SectorMinimal] # Top 3 sectors from fund + compatibility_score: float # 0 to 1 (default 1 for now) + + class Config: + from_attributes = True + + class PaginatedResponse(BaseModel, Generic[T]): """Generic paginated response schema"""