From 0f7beca5e12557410c0de6031b62039babddaf88 Mon Sep 17 00:00:00 2001 From: bolade Date: Thu, 25 Sep 2025 17:00:38 +0100 Subject: [PATCH] made version 2 --- README.md | 577 ---------------- app/__pycache__/main.cpython-312.pyc | Bin 3401 -> 3631 bytes app/__pycache__/py_schemas.cpython-312.pyc | Bin 3550 -> 0 bytes .../pydantic_schemas.cpython-312.pyc | Bin 1640 -> 0 bytes app/__pycache__/schemas.cpython-312.pyc | Bin 3361 -> 0 bytes app/__pycache__/settings.cpython-312.pyc | Bin 672 -> 0 bytes app/api/__pycache__/__init__.cpython-312.pyc | Bin 168 -> 0 bytes app/api/__pycache__/companies.cpython-312.pyc | Bin 9476 -> 0 bytes app/command.py | 46 -- app/db/__pycache__/__init__.cpython-312.pyc | Bin 167 -> 169 bytes app/db/__pycache__/db.cpython-312.pyc | Bin 1623 -> 1788 bytes app/db/__pycache__/models.cpython-312.pyc | Bin 4444 -> 5041 bytes app/db/__pycache__/tables.cpython-312.pyc | Bin 2139 -> 0 bytes app/db/db.py | 6 +- app/db/models.py | 84 ++- app/db/new_schema.py | 115 ---- app/main.py | 29 +- app/pydantic_schemas.py | 38 -- app/{api => routers}/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 174 bytes .../__pycache__/companies.cpython-312.pyc | Bin 0 -> 9573 bytes .../__pycache__/investors.cpython-312.pyc | Bin 9571 -> 9691 bytes app/{api => routers}/companies.py | 92 ++- app/{api => routers}/investors.py | 23 +- .../__init__.py} | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 174 bytes .../__pycache__/py_schemas.cpython-312.pyc | Bin 0 -> 5067 bytes .../router_schemas.cpython-312.pyc | Bin 0 -> 4539 bytes app/schemas/py_schemas.py | 111 ++++ .../router_schemas.py} | 46 +- .../__pycache__/__init__.cpython-312.pyc | Bin 173 -> 175 bytes .../langgraph_agent.cpython-312.pyc | Bin 7268 -> 0 bytes .../__pycache__/llm_parser.cpython-312.pyc | Bin 2916 -> 13264 bytes .../__pycache__/openrouter.cpython-312.pyc | Bin 14261 -> 0 bytes .../__pycache__/openrouter_v2.cpython-312.pyc | Bin 13928 -> 0 bytes .../__pycache__/querying.cpython-312.pyc | Bin 10247 -> 5346 bytes .../__pycache__/settings.cpython-312.pyc | Bin 681 -> 0 bytes app/services/llm_parser.py | 627 +++++++++--------- app/services/openrouter.py | 293 -------- app/services/openrouter_v2.py | 290 -------- app/services/querying.py | 308 ++------- app/settings.py | 11 - 42 files changed, 660 insertions(+), 2036 deletions(-) delete mode 100644 README.md delete mode 100644 app/__pycache__/py_schemas.cpython-312.pyc delete mode 100644 app/__pycache__/pydantic_schemas.cpython-312.pyc delete mode 100644 app/__pycache__/schemas.cpython-312.pyc delete mode 100644 app/__pycache__/settings.cpython-312.pyc delete mode 100644 app/api/__pycache__/__init__.cpython-312.pyc delete mode 100644 app/api/__pycache__/companies.cpython-312.pyc delete mode 100644 app/command.py delete mode 100644 app/db/__pycache__/tables.cpython-312.pyc delete mode 100644 app/db/new_schema.py delete mode 100644 app/pydantic_schemas.py rename app/{api => routers}/__init__.py (100%) create mode 100644 app/routers/__pycache__/__init__.cpython-312.pyc create mode 100644 app/routers/__pycache__/companies.cpython-312.pyc rename app/{api => routers}/__pycache__/investors.cpython-312.pyc (81%) rename app/{api => routers}/companies.py (72%) rename app/{api => routers}/investors.py (93%) rename app/{services/langgraph_agent.py => schemas/__init__.py} (100%) create mode 100644 app/schemas/__pycache__/__init__.cpython-312.pyc create mode 100644 app/schemas/__pycache__/py_schemas.cpython-312.pyc create mode 100644 app/schemas/__pycache__/router_schemas.cpython-312.pyc create mode 100644 app/schemas/py_schemas.py rename app/{py_schemas.py => schemas/router_schemas.py} (66%) delete mode 100644 app/services/__pycache__/langgraph_agent.cpython-312.pyc delete mode 100644 app/services/__pycache__/openrouter.cpython-312.pyc delete mode 100644 app/services/__pycache__/openrouter_v2.cpython-312.pyc delete mode 100644 app/services/__pycache__/settings.cpython-312.pyc delete mode 100644 app/services/openrouter.py delete mode 100644 app/services/openrouter_v2.py delete mode 100644 app/settings.py diff --git a/README.md b/README.md deleted file mode 100644 index d5890cc..0000000 --- a/README.md +++ /dev/null @@ -1,577 +0,0 @@ -# LLM-Powered Investor & Company Management API - -A comprehensive FastAPI-based system for managing investor and company data with LLM-powered CSV parsing, semantic search, and advanced filtering capabilities. - -## Features - -- **FastAPI REST API**: Modern, auto-documented API with OpenAPI/Swagger support -- **CSV Data Processing**: Parse complex investor data from CSV files using LLM assistance -- **Dual Database Storage**: Structured data in SQL database and semantic search via ChromaDB -- **Natural Language Queries**: AI-powered query processing for complex investor searches -- **Advanced Filtering**: Filter investors and companies by multiple criteria -- **Relationship Management**: Many-to-many relationships between investors, companies, and sectors -- **Auto-Generated Documentation**: Interactive API docs at `/docs` - -## Architecture - -### Components - -1. **FastAPI Application (`app/main.py`)**: Main API server with route configuration -2. **Database Models (`app/db/models.py`)**: SQLAlchemy models for investors, companies, sectors -3. **Pydantic Schemas (`app/py_schemas.py`)**: Request/response validation and serialization -4. **API Routes**: - - `app/api/investors.py`: Investor CRUD operations and filtering - - `app/api/companies.py`: Company CRUD operations and filtering -5. **Services**: - - `app/services/openrouter.py`: LLM-powered CSV processing - - `app/services/querying.py`: Natural language query processing -6. **Database (`app/db/`)**: Database connection, models, and schemas - -### Data Flow - -``` -CSV Upload → LLM Processing → Data Extraction → SQL Storage → Vector Storage → API Endpoints - ↓ -Natural Language Query → AI Analysis → Database Filtering → Structured Response -``` - -## Installation - -### Prerequisites - -- Python 3.12+ -- FastAPI and dependencies - -### Setup - -1. Clone the repository and navigate to the project directory: - -```bash -cd /path/to/anton_wireframe -``` - -2. Install dependencies: - -```bash -pip install -r requirements.txt -``` - -3. Configure environment variables: - -```bash -cp .env.example .env -# Edit .env and add your OpenRouter API key for LLM features -``` - -4. Initialize the database: - -```bash -cd app -python -c "from db.db import init_database; init_database()" -``` - -5. Start the API server: - -```bash -cd app -uvicorn main:app --reload --host localhost --port 8000 -``` - -The API will be available at: - -- **API Base**: http://localhost:8000 -- **Interactive Docs**: http://localhost:8000/docs -- **ReDoc**: http://localhost:8000/redoc - -## Database Schema - -### SQL Database (SQLite) - -#### Investors Table - -- **Basic Info**: name, description, geographic_focus -- **Investment Data**: aum, check_size_lower, check_size_upper -- **Stage Focus**: investment stage (SEED, SERIES_A, etc.) -- **Relationships**: Many-to-many with companies and sectors -- **Team**: One-to-many with team members -- **Metadata**: created_at, updated_at timestamps - -#### Companies Table - -- **Basic Info**: name, industry, location -- **Details**: founded_year, website -- **Relationships**: Many-to-many with investors -- **Metadata**: created_at, updated_at timestamps - -#### Association Tables - -- **investor_companies**: Links investors to their portfolio companies -- **investor_sectors**: Links investors to their focus sectors -- **investor_team**: Team member details for each investor - -#### Supporting Tables - -- **sectors**: Investment focus areas (fintech, healthcare, etc.) - -### Vector Database (ChromaDB) - -Stores embeddings for semantic search of: - -- Investor descriptions -- Investment thesis focus areas -- Combined investor profiles - -## API Usage - -### Interactive Documentation - -Visit http://localhost:8000/docs for the auto-generated Swagger UI where you can: - -- Explore all endpoints -- Test API calls directly -- View request/response schemas -- See example requests - -### Core Endpoints - -#### Investor Management - -```bash -# Get all investors with relationships -GET /investors - -# Filter investors by criteria -GET /investors/filter?stage=GROWTH&geography=US§or=fintech&min_check_size=1000000 - -# Get specific investor -GET /investors/{investor_id} - -# Create new investor -POST /investors -{ - "name": "Example VC", - "description": "Early stage fintech investor", - "aum": 50000000, - "check_size_lower": 100000, - "check_size_upper": 2000000, - "geographic_focus": "US", - "stage_focus": "SEED", - "number_of_investments": 25 -} - -# Update investor -PUT /investors/{investor_id} - -# Delete investor -DELETE /investors/{investor_id} -``` - -#### Company Management - -```bash -# Get all companies with investor relationships -GET /companies - -# Filter companies by criteria -GET /companies/filter?industry=fintech&location=San Francisco&founded_after=2015 - -# Get specific company -GET /companies/{company_id} - -# Create new company -POST /companies -{ - "name": "Example Startup", - "industry": "fintech", - "location": "San Francisco", - "founded_year": 2020, - "website": "https://example.com" -} - -# Update company -PUT /companies/{company_id} - -# Delete company -DELETE /companies/{company_id} -``` - -#### CSV Processing - -```bash -# Upload and process CSV file -POST /parse-csv -Content-Type: multipart/form-data -File: investors.csv -``` - -#### Natural Language Queries - -```bash -# Query investors using natural language -POST /query -{ - "question": "Show me growth stage fintech investors in Silicon Valley with check sizes over $1 million" -} -``` - -### Advanced Filtering Examples - -#### Investor Filters - -```bash -# Early stage investors in Europe -GET /investors/filter?stage=SEED&geography=Europe - -# High AUM growth investors -GET /investors/filter?stage=GROWTH&min_aum=100000000 - -# Healthcare investors with large checks -GET /investors/filter?sector=healthcare&min_check_size=5000000 - -# Specific geographic focus -GET /investors/filter?geography=Silicon Valley -``` - -#### Company Filters - -```bash -# Recent fintech companies -GET /companies/filter?industry=fintech&founded_after=2020 - -# Companies with websites -GET /companies/filter?has_website=true - -# Companies backed by specific investor -GET /companies/filter?investor_name=Sequoia - -# Location-based filtering -GET /companies/filter?location=New York -``` - -### Response Format - -All endpoints return structured JSON with full relationship data: - -```json -{ - "investor": { - "id": 1, - "name": "Example VC", - "description": "Early stage investor", - "aum": 50000000, - "check_size_lower": 100000, - "check_size_upper": 2000000, - "geographic_focus": "US", - "stage_focus": "SEED", - "number_of_investments": 25 - }, - "portfolio_companies": [ - { - "id": 1, - "name": "StartupCo", - "industry": "fintech", - "location": "San Francisco" - } - ], - "team_members": [ - { - "id": 1, - "name": "John Partner", - "role": "Managing Partner", - "email": "john@examplevc.com" - } - ], - "sectors": [ - { - "id": 1, - "name": "fintech" - } - ] -} -``` - -## Data Processing Pipeline - -### 1. CSV Parsing - -- Reads CSV with pandas -- Handles nested JSON fields in columns -- Validates data with Pydantic models - -### 2. JSON Field Processing - -- Direct parsing for well-formed JSON -- LLM-assisted cleaning for malformed JSON (when enabled) -- Graceful fallback to empty objects - -### 3. Data Extraction - -Extracts key fields: - -- Company name and website -- Investor description -- Investment thesis/focus areas -- Headquarters location -- Assets Under Management (AUM) -- Fund information - -### 4. LLM Enhancement (Optional) - -When `--use-llm` is enabled: - -- Standardizes investor descriptions -- Normalizes investment focus areas -- Cleans headquarters location format -- Repairs malformed JSON data - -### 5. Dual Storage - -- **SQL Database**: Structured, queryable data -- **Vector Database**: Semantic search capabilities - -## Configuration - -### Environment Variables (.env) - -```bash -# OpenRouter API Configuration (required for LLM features) -OPENROUTER_API_KEY=your_openrouter_api_key_here - -# Database Configuration (optional, defaults to SQLite) -DATABASE_URL=sqlite:///investors.db - -# FastAPI Configuration -API_HOST=localhost -API_PORT=8000 -``` - -### LLM Configuration - -- **Provider**: OpenRouter (supports multiple models) -- **Default Model**: google/gemini-2.5-flash-lite -- **Temperature**: 0.3 for enhancement, 0 for structured data -- **Fallback**: Graceful degradation when API unavailable - -## Natural Language Query Processing - -The system supports intelligent natural language queries that automatically extract filters and search criteria: - -### Query Examples - -```bash -# Stage-based queries -"Show me seed stage investors" -"Find growth stage VCs" - -# Geographic queries -"Investors in Silicon Valley" -"European venture capital firms" - -# Sector-specific queries -"Fintech investors" -"Healthcare and biotech VCs" - -# Size-based queries -"Investors with $5M+ check sizes" -"High AUM growth investors" - -# Combined queries -"Growth stage fintech investors in the US with check sizes over $1 million" -"European healthcare investors focusing on early stage" -``` - -### Query Processing Features - -- **Automatic Filter Extraction**: Detects investment stages, geographies, sectors, and check sizes -- **Semantic Understanding**: Uses AI to interpret complex queries -- **Database Integration**: Combines AI analysis with efficient SQL filtering -- **Complete Relationships**: Returns full investor data with portfolio companies, team members, and sectors - -### Query Response - -The `/query` endpoint returns a structured `InvestorList` with complete relationship data, making it easy to get comprehensive information about matching investors. - -## Error Handling - -### API Error Responses - -The API provides clear HTTP status codes and error messages: - -```json -// 404 Not Found -{ - "detail": "Investor not found" -} - -// 422 Validation Error -{ - "detail": [ - { - "loc": ["body", "stage_focus"], - "msg": "value is not a valid enumeration member", - "type": "type_error.enum" - } - ] -} -``` - -### Robust Processing - -- **Data Validation**: Pydantic models ensure data integrity -- **Relationship Management**: Automatic handling of foreign key constraints -- **LLM Fallbacks**: Graceful degradation when AI services unavailable -- **Transaction Safety**: Database rollbacks on errors -- **Comprehensive Logging**: Detailed error tracking and debugging - -### Common Issues and Solutions - -1. **Invalid Enum Values** - - - Solution: Use uppercase enum values (SEED, GROWTH, etc.) - - Check: Investment stages must match defined enum - -2. **Missing OpenRouter API Key** - - - Solution: Set OPENROUTER_API_KEY in environment - - Fallback: CSV processing continues without LLM enhancement - -3. **Database Connection Issues** - - - Solution: Verify DATABASE_URL configuration - - Default: Uses SQLite (no external dependencies) - -4. **Relationship Errors** - - Solution: Ensure proper foreign key relationships - - Check: Use existing sector/company IDs or create new ones - -## Performance - -### Benchmarks (Approximate) - -- **API Response Time**: <200ms for standard queries -- **Database Queries**: <50ms for filtered searches with relationships -- **CSV Processing**: ~5-15 seconds per row (depends on LLM API latency) -- **Natural Language Queries**: ~2-5 seconds (AI processing + database query) -- **Vector Search**: <100ms for semantic similarity queries - -### Optimization Features - -1. **Eager Loading**: Efficient relationship loading with `selectinload()` -2. **Query Optimization**: Smart filtering to reduce database load -3. **Caching**: Database connection pooling and session management -4. **Pagination**: Built-in limits to prevent overwhelming responses -5. **Async Processing**: FastAPI async capabilities for better performance - -### Production Recommendations - -1. **Database**: Consider PostgreSQL for production workloads -2. **Caching**: Add Redis for frequently accessed data -3. **Load Balancing**: Deploy multiple API instances behind a load balancer -4. **Monitoring**: Implement logging and metrics collection -5. **Rate Limiting**: Add API rate limiting for public endpoints - -## File Structure - -``` -anton_wireframe/ -├── app/ -│ ├── main.py # FastAPI application and main endpoints -│ ├── py_schemas.py # Pydantic models for validation -│ ├── settings.py # Configuration management -│ ├── api/ -│ │ ├── __init__.py -│ │ ├── investors.py # Investor CRUD and filtering endpoints -│ │ └── companies.py # Company CRUD and filtering endpoints -│ ├── db/ -│ │ ├── __init__.py -│ │ ├── db.py # Database connection and session management -│ │ ├── models.py # SQLAlchemy database models -│ │ └── new_schema.py # Additional schema definitions -│ └── services/ -│ ├── __init__.py -│ ├── openrouter.py # LLM-powered CSV processing -│ ├── querying.py # Natural language query processing -│ └── langgraph_agent.py # AI agent configuration -├── chroma_db/ # Vector database directory -├── requirements.txt # Python dependencies -├── README.md # This documentation -└── .env # Environment configuration -``` - -## Example Usage Scenarios - -### 1. Upload and Process Investor Data - -```bash -# Upload CSV file via API -curl -X POST "http://localhost:8000/parse-csv" \ - -H "Content-Type: multipart/form-data" \ - -F "file=@investors.csv" -``` - -### 2. Find Specific Investors - -```bash -# Natural language search -curl -X POST "http://localhost:8000/query" \ - -H "Content-Type: application/json" \ - -d '{"question": "Show me growth stage fintech investors in Silicon Valley with check sizes over $2 million"}' - -# Structured filtering -curl "http://localhost:8000/investors/filter?stage=GROWTH§or=fintech&geography=Silicon%20Valley&min_check_size=2000000" -``` - -### 3. Company Research - -```bash -# Find companies in specific sector -curl "http://localhost:8000/companies/filter?industry=fintech&founded_after=2020" - -# Find companies backed by specific investor -curl "http://localhost:8000/companies/filter?investor_name=Sequoia" -``` - -### 4. Investment Analysis - -```bash -# Get investor with full portfolio -curl "http://localhost:8000/investors/1" - -# Find all companies in a specific location -curl "http://localhost:8000/companies/filter?location=San%20Francisco" -``` - -## Development - -### Running in Development Mode - -```bash -cd app -uvicorn main:app --reload --host localhost --port 8000 -``` - -### Testing the API - -1. **Interactive Testing**: Visit http://localhost:8000/docs -2. **Manual Testing**: Use curl or Postman with the examples above -3. **Database Inspection**: Use SQLite browser to inspect `investors_2.db` - -### Adding New Features - -1. **New Endpoints**: Add routes to `api/investors.py` or `api/companies.py` -2. **New Models**: Update `db/models.py` and `py_schemas.py` -3. **New Filters**: Extend filtering logic in route handlers -4. **New LLM Features**: Modify `services/openrouter.py` or `services/querying.py` - -## License - -This project is part of the MKD Anton Wireframe system. - -## Support - -For issues and questions: - -1. Check logs for detailed error messages -2. Verify environment configuration -3. Test with limited datasets first -4. Review CSV data format requirements diff --git a/app/__pycache__/main.cpython-312.pyc b/app/__pycache__/main.cpython-312.pyc index 9dcd9436bfe5a15926f16e9ebd7468ed8cca6c89..8132a27985d4cb90f1599741e74d563c1440a779 100644 GIT binary patch delta 2048 zcmZ`)|8EpU6rb7M+r97C-nG3i{dR?x_Q3W+pzZ4q<)-;rm82QNq`~mS7XZCuB5}jn;yw7`Y z-n^an=I!^fZ(6#4@cG<;j(6mrX8)1^c)&n)IJcQ66PLP^9taRXg6hn<(wF3}ogR?w3wMB+eYC(3;y@zVC)fikq zH({>9*64q0>nhsGPSpHi4*;Z@CIqTo=`I>}*W8hsn;L^!P%COf?bqY0LPZF{0$hNJ zj)x2$H4)b_1}%@8mr(}g4Xn*41WTHbbz}V4q$Nx!DyB3~a+doI)zU7RPW~wBulpIZnq=^Bxhp;^suN%Y)WHPd* zpL}HAbqqt^42q+a9ut#8 zg2CL`qgqf)jztb}c2UF@9dm)I{`tBnm}_FRS=6Fb&%Wk`QPGFRMe!iGAZ583kmXL& zd@f0g(p<>g5_k4V$H5Tz{0g08bneP>klHLsXTaC|8JOUYo^=$B>E7Xo>{TY*=8&}K zH9uvoKtU!tu6?Fn6=d_df~+Z6x7;~aN*Pql6%tO;O*au*-+&~{^HNVYYeVtvLG1f= zRXk?(dGZQXvTN3_m_JLS`!?!!u1s!8!He>bQut1!<91~4t;pWB9UGB>+rfd)q^%c4 z=Owe(8O=mNApW%M2cf~ITfu>6k450^{ZpTyjz4KgjlwTrs$aM~-bUFQ12DB;xG@-| z{LKJN4GK5woQ#izQ^Uf|q`>(7kkLV#4m(n#<^^Zd;a$`l>7vAv2tHk;FCx3$z^DuW zmVG8#m_Kmu@dClF) z_3SpkbElTONs_89ktPqxI_YJ=fll?IQ-rxAtkqazrJWE51JX3@C~`p|BGa?dCM>DM z#gxv%0+KaD$rAP_qIZ)o8kp$B!z{c^)TC!;aZc9zY*iP@Y-7USffdp*nOEqS_Ni(v zV^;!6JeKN`4ugz77@-= zqOruvYIhJzD$?`vM6-BO)c(;bSZzLYj?iK*s(6e{9iW(HP~S~ynTHVm0$dM3=pN|X z1a0@gz74R?W_mV3aue*`1TWnOo|5k_@Rb7hMc+APg*V^xP9C{6_5S+F4>qRcN5Btz zzx0*a@>SYi*55wyn87DJ;10twN7?cYz&SpRd>lF7b9H3x?3%g~*?he*=~(*FXRO delta 1889 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 K{wDAd|NI{-Hu@z1 diff --git a/app/__pycache__/pydantic_schemas.cpython-312.pyc b/app/__pycache__/pydantic_schemas.cpython-312.pyc deleted file mode 100644 index f3767d2ff80017c58cc5b8db4ce4463e3457fadc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1640 zcmb_c&1(}u6rag%w!3N4H2t=a(ho#(X%3!*(t{LgiK!@vkb}#ToibUNni=ZP}BNM@3Ul|8GSa=XC|_iRY*y5D|p336mqEtj?bj# zG<}(>)>w7V7T#+n(}GpOZ105CrjVl|a&}pFU~snUg@Md@Y7d_UksZ~T@PwR-C0OS& zQRQAEf{EK_hBR#(&#`TpvTZ+fn|0)A+um{V?6 zSM(jR+GueDc2s~wtOC3t?NsJ)t8JwZAGOCP51+IrrqA{1c5doipK7PGho$!H{JB2g zp3h(Cc|P0QV0a!Ezf>8USuIIeTh;SijJh;)ytCxW6^9-1hf>)}5+jOI2X1Fe)=on~b-f zG0}iLGd=>Ra}&Wrz<16^5%3y3gD?glq4*pDHN~?i-3mY+HH2ws;y%DXr1V_RLynJ1 zah!0M;c>!H506uv(eAzWoAAD*sXBZFh2-sIPQpffSRNPAfUpEjU}WGLW4WVp>>65z zyY^Qlm%9;oRd*Kera}Cv=2T>d`MLtmaD=ENS|&DrG@5l~8G`Ktd9l1eK&hY*kr~J)2mp?d{l0 zw471}q|#G3k#lc|dyoAGdLi12I}+-lr(UYI*{Tw!zBk@%)(a?6E&2J)oAG?S-|xLQ zKWDQk0iJ7r{kZW*LJ)q##=#@El>MJTc_NsCDOLnqEQuoOu}aL2m110vSK_u*lI%n& z!P`?u1PA;P)*$@zGag_j7f`@=QH(Y)wf(n z$6jgHso6%jxloF&#{MKIPXr>AL{limOtBOc#N^Mj3bjP-eGpp|b>w>Rj>!7mYixA-FLe+1o! z{{B)RaUz-G6|j;7Xff0{_Hn>odKQBwJSDlFD99|cLJZ%fD|~}2W|>SvUqx1GBijrA z9j=7P)?mAP_h_}IzxF(Z09v#3* zwAzVQTW+I|UQ>~{98J4}~+Ev}DhiK9<7)!@NFx7I*8n~m*)QW3x z53x+ytvMz!wK~x$Q#Q$(XZeJs3`!ubOu&hyYE?`Es7E|-6d?v8`)D(QPT+>3poaim zf)3vKHjdzo;7}KmT~O0^alm^h3MeoQQv~F-K);V2D2CwmZh+_nI>_~HFNLCiWcSkJ z4;s>VkR9AH9(~=AMuOg<-J!kSiH0=Mp>~r4Cy%(MO%7b`|B!=&%d0{gT$$&}p)gmv zbd~7#60z4vD{4D%mZ)1HECIR3szkPR6k_9Vn*12xU51mMgO_%JN8dqX7u-{5LxG(< z{Y4W#$O^QYCHM|=sD8SGC7E_FcvXIhOvlR7=@^X@rP+Mj64N29&{wxToD)lRpgGXtqM+XgUM&lP%D?!d>dglF0$oEykp+EyiG`Fl-?JDch3*at#vD=$R@nbN$ z_hYbP2A22)*Xm+LA$6RP=2h~Ias4sQivkbh-2o9HQQz?HTaQN@(pZqpY|jTLha1vx zME2zyQa(66hC_}O=D61D*;j#{PWE9@Boax4Wp^FQEKx|vmxRf=2eu^GQk+9@B|^@C zEe*Cz%f|W06M+moQrbMq)>XLZWDc;H(S7~?6y{n=Hi+X{_eeg3+NF6&+s@UlP2C3yz}9 ziOy{vgD{KWhk1BkAxy4|Fig=j(={}WQy?q`{71N>hv}X!pvF@Trs1&QPojnnKD=?z z>)1pw4X=lkeJK1QeHP@-G^8^@@#1so)b7Bpy*GTWAzchEO>HgiOg=wV-0Q#4kfwr( z$*sBXR(H(ES&ay%h6-JRIh~h-B0NkFaZMNeFfsrxG0J*gs~UG=&!=r7s6mumc_9h zWB0?R07|nzA&&1{ZVK>e_NGB!XbSLYo>9c{-Fj1iPjes#_B%}hKFtXg$NwY1hx_n9 D&J61& diff --git a/app/__pycache__/settings.cpython-312.pyc b/app/__pycache__/settings.cpython-312.pyc deleted file mode 100644 index 22723b303fc8c67bbb3bdbbe3ebf109b0cf575ab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 672 zcmY*W&ubGw6n?Y2iEB+7tqR)WAFv|Xizg|9wn3zrmTD3AG7OnXySO_OXEv6I7omdU zAJD%+;=%vHqenr?f+tVj)D(oAe6v544D9#5H~Y=|-pu>lZZ83@PhUSCe8BkKCiQAm zWOftDF>v4{fs{mqfCKk{J4e7>L8fl0iyQ}pGp!Cq?!*fkKWr9|ymglu@la%0EceZz zk`%$KlGzO;#~>gg93p2AgeyEw)^Yd0+0-f3QNtF`{kmelvc1#m?{4os>h01yJNM~U z@5zD-sa?)5xNw4PI)OXy)Rnp$N)F@w7hYG$=l#ITeM%)u1*Q2CrK#fM#PSxU&&DjN z{`jb+!#EM|VP=iy8{vUUMW~YTAu~*-ak!}l<5bAZgpZYe8a~+C44KT7q=&H+Q*7#@G};U;N;@fx4vFRuI**|K~tZ#iJhZ9hmgA_(|LzdCZ)<) z7At8eEyTKQMa9{UWp=^^*eRG5gx7F#W$lZ%_QxZQ)!%?n%B|8q$A!^^W8?9F)~#wg z{-Q|k|Jz&infvSI%iY$OQEZ1{t|9z%2_fI$`f1Z6t8ZSO0)}79(7N>Q)~oW0e*qSD Bo!$Td diff --git a/app/api/__pycache__/__init__.cpython-312.pyc b/app/api/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 707593a0b1e3feed607800fb0dee58913358f498..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 168 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 diff --git a/app/command.py b/app/command.py deleted file mode 100644 index 952d47c..0000000 --- a/app/command.py +++ /dev/null @@ -1,46 +0,0 @@ -from sqlalchemy.orm import Session -from db.models import InvestorTable -from db.db import get_db - -def update_stage_focus_values(): - """Update existing stage_focus values from lowercase to uppercase""" - db = next(get_db()) - - try: - # Mapping of old lowercase values to new uppercase values - stage_mappings = { - 'seed': 'SEED', - 'series_a': 'SERIES_A', - 'series_b': 'SERIES_B', - 'series_c': 'SERIES_C', - 'growth': 'GROWTH', - 'late_stage': 'LATE_STAGE' - } - - updated_count = 0 - - for old_value, new_value in stage_mappings.items(): - # Update records with the old value - result = db.query(InvestorTable).filter( - InvestorTable.stage_focus == old_value - ).update( - {InvestorTable.stage_focus: new_value}, - synchronize_session=False - ) - - updated_count += result - print(f"Updated {result} records from '{old_value}' to '{new_value}'") - - db.commit() - print(f"Successfully updated {updated_count} total records") - - except Exception as e: - db.rollback() - print(f"Error updating stage_focus values: {e}") - raise - finally: - db.close() - -# Run the update -if __name__ == "__main__": - update_stage_focus_values() \ No newline at end of file diff --git a/app/db/__pycache__/__init__.cpython-312.pyc b/app/db/__pycache__/__init__.cpython-312.pyc index 3173eb0374a22b0b98187be7befc0471db634573..3386371587876926a7f7080af5bfdafd44f0c836 100644 GIT binary patch delta 31 lcmZ3^xRR0kG%qg~0}!mryEu{Cn9*aRy%BSKnbE`|QviXf2$BE* delta 29 jcmZ3bn9*&by%A&T#1c~gZGs22 diff --git a/app/db/__pycache__/db.cpython-312.pyc b/app/db/__pycache__/db.cpython-312.pyc index 51bb0e04a44506c1c14823f7647dce2426faea29..027f602e6f4b5cbf1484e408d2768466df1bf19a 100644 GIT binary patch delta 243 zcmcc4^M{x3G%qg~0}$L;aWO-fWg?#hrJj@e8uQAIfKcFIljzj^K2${CdQSM53^{lR6Wl=H#<18glYL-D*Hw3^J#I;R&~Rx7`I6$s%bWS5tKI64r%` n@{_&UWVtke0*oMqwUdk33|Sm6^E*vm&gRDTg@K7t3alIe<{2X@ diff --git a/app/db/__pycache__/models.cpython-312.pyc b/app/db/__pycache__/models.cpython-312.pyc index e6daac03b0c7e80cd371cc1c38abe14a35a31876..bf0f112f4bbfa146d4ee5a3c93b7a33cdeeaa2f9 100644 GIT binary patch literal 5041 zcmcgvO>7&-6<(6dQZeXc2`!a1rd9P@R5kSXb<;^OT84Maw6E(GG&g0|jFKBgmxjHUt)Bw(i9iJ< zKm;WSP7HBJ2G)aqkl;Oh!!L!dHT)DMp>@$uBn1=fYp<{#_S0UG>rye|q|g<1RWmAc zc%DT#f?NgB2bM{v;m9eQvRjm?gf45fa@CQ|TDgp_!GV@ps}OMKd@<_Cs%mNmRaGaZ zs)j{sW#kj8`d&>dyBdbs&Cnk#4#WwW)@>(NVicAjs#bMk?sCX~CTyVtzMP)8ZW(j} zVs~4!HPg^1CaqGaABNv`S@yEKR^ZXxSdG z)L90tB8kG^J_Y1M;c>RGDQB7oTk^j9@_q3)@-O9I#U6fdI~2@Gjmu9ZAvgGQdF^0p zaN@zy2M2$D^tVTUcdV72x^tP0`m6Os+ADQo>maCiB25BHhy)dpgp@E56^VqE2$8`5 z5lGAQjCl+Ej|W<)YAcX&3sX~*j=V7S_RQ3RdalEr?{F6!>Ehd0uPt73VzcKKr__bT za~G!|1gsx-HGrgmWDLoEBnObZgamiS4j~yq!q2e>xqUtXWESi(ARh|tc(yUwP7gIM zb&+I$W4fK)<2wQ8IXRsl!eVO4GAf#>Q`?DmWIFjW;lJ49I)RY$QFRqHof)Xf_~TTY zmZ}!Bov6#e&<|ps3>4wJY>P^7Kux=9i{`6-7 z)(3%R*fOE;BR(1=F1V58>%d%$mSqx{?WC5p(oMBuRce5bkPj)}<{UMa0BB|3D6IV^ zCQ*EWhnz1pUmV(%%}!EPtB5<@P!hp;L7gxe84*!*Fd0r^ZOcekE7|;$lb;J`BwBq#caPF}ax=Z06U*R_=J? z%4RIHS^#;h*toD+*wb9OA8r*+JQDN&@v^*DYZYEc4S-|5X>AORKj?4e-hwqo58Qp@ z{<+pD%o_sm968dK2im#ucHzKwL>!SGiG`;zAwTwL?B$2Ut-jgD>}G1XE%&!`$K4@m z>CQzLk?2_Wrf9^BoQJ*`FR&eB~_0V z6u?x#UI;t@T8%1IB*^&Hp8Gksi?Y0lFK=U4ZT# zhX9=-5{azKow%mCEJ`yZwjS?*Ey8k&BxnwRbw}D)PZm?`1RT?&ZdT((LaZ`e89P-5At0gMy1PBrF4Zqp@O zuTmGTB3_=dOKA6H96s#r%CsDk_K$;}R``~)=5&P})`_*L#*6MRHaWXJ<(>792E zOFHk}I3oH|5?+@&ne==tS%3{WX+I7=loSK(RZy^FKs-d@zJC{m-v~_PvToj_L^m0t zg9|4`ASdA8nqmD@7HsgfJ(l}XSA&@yS z`@OPOh6|HX@A_opy@q!r-i+`&ieI+eZ@JXYoNZdLd#=3cF2 zgH|>8jMQ8V;=bW-;hhpY3P%?=>$qEbUG=4W+Wp=<%P0)SuQ2-*&?lilAn<1){&!*E z3*p+IglikZwJ(H|e-ZXSk%VC1zk*_5I`Bk5x)lP!6E4_JNrAnqM^&D?nD%!YUt1*dn~qIhO28XgNi5=Niqgnb9w;=qe0 zp5nDIyRrYx4e=yQ+y@g!o@Ru-FSW&d*TfzJXmR(%y)bcjJ2M!NRwtX|+X67#C*;6v tV0E#1c3VJhd!Qe*uQm5>3&3o@EC;3n?qKA$2hyND*i8LX0EQ>XzX66^o=*S( literal 4444 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 F{{~Nq(oFyW diff --git a/app/db/db.py b/app/db/db.py index 401bdd6..27eace0 100644 --- a/app/db/db.py +++ b/app/db/db.py @@ -9,7 +9,7 @@ from sqlalchemy.orm import Session, sessionmaker Base = declarative_base() # Database configuration -DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///investors.db") +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./investors.db") # Create engine engine = create_engine(DATABASE_URL, echo=False) @@ -38,3 +38,7 @@ def init_database(): def get_session_sync() -> Session: """Get a database session for synchronous operations""" return SessionLocal() + +def get_db_session(): + """Get a database session for direct use.""" + return SessionLocal() diff --git a/app/db/models.py b/app/db/models.py index 8ab6287..0d7f8fc 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -1,11 +1,17 @@ -import datetime import enum -from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Table, Text -from sqlalchemy.orm import relationship +from db.db import Base +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Table, Text, func +from sqlalchemy.orm import declarative_mixin, relationship from sqlalchemy.types import Enum -from db.db import Base + +@declarative_mixin +class TimestampMixin: + created_at = Column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) class InvestmentStage(enum.Enum): @@ -16,6 +22,7 @@ class InvestmentStage(enum.Enum): GROWTH = "GROWTH" LATE_STAGE = "LATE_STAGE" + # Association table for many-to-many relationship between investors and companies investor_company_association = Table( "investor_companies", @@ -34,7 +41,15 @@ investor_sector_association = Table( ) -class InvestorTable(Base): +company_sector_association = Table( + "company_sector", + Base.metadata, + Column("company_id", Integer, ForeignKey("companies.id")), + Column("sector_id", Integer, ForeignKey("sectors.id")), +) + + +class InvestorTable(Base, TimestampMixin): __tablename__ = "investors" id = Column(Integer, primary_key=True, index=True) @@ -46,12 +61,6 @@ class InvestorTable(Base): geographic_focus = Column(String, nullable=False) stage_focus = Column(Enum(InvestmentStage), nullable=False) number_of_investments = Column(Integer, default=0) - created_at = Column(DateTime, default=datetime.datetime.now(datetime.UTC)) - updated_at = Column( - DateTime, - default=datetime.datetime.now(datetime.UTC), - onupdate=datetime.datetime.now(datetime.UTC), - ) # Relationship to portfolio companies portfolio_companies = relationship( @@ -59,7 +68,7 @@ class InvestorTable(Base): secondary=investor_company_association, back_populates="investors", ) - team_members = relationship("InvestorTeamMember", back_populates="investor") + team_members = relationship("InvestorMember", back_populates="investor") sectors = relationship( "SectorTable", secondary=investor_sector_association, @@ -67,22 +76,29 @@ class InvestorTable(Base): ) -class CompanyTable(Base): +class InvestorMember(Base, TimestampMixin): + __tablename__ = "investor_members" + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + role = Column(String, nullable=False) + email = Column(String, nullable=False) + + investor_id = Column(Integer, ForeignKey("investors.id")) + investor = relationship("InvestorTable", back_populates="team_members") + + +class CompanyTable(Base, TimestampMixin): __tablename__ = "companies" id = Column(Integer, primary_key=True, index=True) name = Column(String, nullable=False) industry = Column(String, nullable=False) location = Column(String, nullable=False) + description = Column(String, nullable=True) founded_year = Column(Integer, nullable=True) website = Column(String, nullable=True) - created_at = Column(DateTime, default=datetime.datetime.now(datetime.UTC)) - updated_at = Column( - DateTime, - default=datetime.datetime.now(datetime.UTC), - onupdate=datetime.datetime.now(datetime.UTC), - ) + members = relationship("CompanyMember", back_populates="company") # Relationship back to investors investors = relationship( "InvestorTable", @@ -90,8 +106,23 @@ class CompanyTable(Base): back_populates="portfolio_companies", ) + sectors = relationship( + "SectorTable", secondary=company_sector_association, back_populates="companies" + ) -class SectorTable(Base): + +class CompanyMember(Base, TimestampMixin): + __tablename__ = "company_members" + id = Column(Integer, primary_key=True) + name = Column(String) + linkedin = Column(String) + role = Column(String) + company_id = Column(Integer, ForeignKey("companies.id"), nullable=False) + + company = relationship("CompanyTable", back_populates="members") + + +class SectorTable(Base, TimestampMixin): __tablename__ = "sectors" id = Column(Integer, primary_key=True, index=True) @@ -104,13 +135,6 @@ class SectorTable(Base): back_populates="sectors", ) - -class InvestorTeamMember(Base): - __tablename__ = "investor_team" - id = Column(Integer, primary_key=True, index=True) - name = Column(String, nullable=False) - role = Column(String, nullable=False) - email = Column(String, nullable=False) - - investor_id = Column(Integer, ForeignKey("investors.id")) - investor = relationship("InvestorTable", back_populates="team_members") + companies = relationship( + "CompanyTable", secondary=company_sector_association, back_populates="sectors" + ) diff --git a/app/db/new_schema.py b/app/db/new_schema.py deleted file mode 100644 index 5928872..0000000 --- a/app/db/new_schema.py +++ /dev/null @@ -1,115 +0,0 @@ -import json -from typing import List, Optional - -from pydantic import BaseModel -from sqlalchemy import JSON, Column, DateTime, Integer, String, Text -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.sql import func - -Base = declarative_base() - - -class Investor(Base): - __tablename__ = "investors" - - id = Column(Integer, primary_key=True, autoincrement=True) - name = Column(String(500), nullable=False) - website = Column(String(1000)) - - # Core investment information - investor_description = Column(Text) - investment_thesis_focus = Column(JSON) # List of focus areas - headquarters = Column(String(1000)) - - # AUM information - aum_amount = Column(String(200)) - aum_as_of_date = Column(String(100)) - aum_source_url = Column(String(1000)) - - # Fund information - funds_info = Column(JSON) # Complex fund data - - # Raw data columns for reference - crunchbase_urls = Column(Text) - crunchbase_extract = Column(Text) - linkedin_profile = Column(Text) - source_truth_profile = Column(Text) - - # Metadata - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), onupdate=func.now()) - - def __repr__(self): - return f"" - - -# Pydantic models for data validation and parsing -class AUMInfo(BaseModel): - aumAmount: Optional[str] = None - asOfDate: Optional[str] = None - sourceUrl: Optional[str] = None - - -class FundInfo(BaseModel): - fundName: Optional[str] = None - fundSize: Optional[str] = None - vintage: Optional[str] = None - status: Optional[str] = None - description: Optional[str] = None - - -class InvestorProfile(BaseModel): - websiteURL: Optional[str] = None - investorDescription: Optional[str] = None - investmentThesisFocus: Optional[List[str]] = None - headquarters: Optional[str] = None - overallAssetsUnderManagement: Optional[AUMInfo] = None - funds: Optional[List[FundInfo]] = None - - -class CSVRow(BaseModel): - name: str - website: Optional[str] = None - investment_firm_profile: Optional[str] = None - crunchbase_linkedin_urls: Optional[str] = None - crunchbase_firm_extract: Optional[str] = None - linkedin_investment_profile: Optional[str] = None - source_of_truth_profile: Optional[str] = None - - def get_combined_description(self) -> str: - """Combine all description fields for vector embedding""" - descriptions = [] - - if self.investment_firm_profile: - try: - profile_data = json.loads(self.investment_firm_profile) - if isinstance(profile_data, dict): - desc = profile_data.get("investorDescription", "") - if desc: - descriptions.append(desc) - except (json.JSONDecodeError, TypeError): - pass - - if self.crunchbase_firm_extract: - descriptions.append(self.crunchbase_firm_extract) - - if self.linkedin_investment_profile: - descriptions.append(self.linkedin_investment_profile) - - if self.source_of_truth_profile: - descriptions.append(self.source_of_truth_profile) - - return " ".join(descriptions) - - def get_investment_focus(self) -> List[str]: - """Extract investment thesis focus""" - if self.investment_firm_profile: - try: - profile_data = json.loads(self.investment_firm_profile) - if isinstance(profile_data, dict): - focus = profile_data.get("investmentThesisFocus", []) - if isinstance(focus, list): - return focus - except (json.JSONDecodeError, TypeError): - pass - return [] diff --git a/app/main.py b/app/main.py index 2eb0264..ac839bf 100644 --- a/app/main.py +++ b/app/main.py @@ -1,17 +1,20 @@ import io import pandas as pd -from api import companies, investors from db.db import db_dependency, init_database -from fastapi import FastAPI, File, UploadFile -from py_schemas import InvestorList +from dotenv import load_dotenv +from fastapi import FastAPI, File, Form, UploadFile from pydantic import BaseModel -from services.openrouter_v2 import InvestorProcessor +from routers import companies, investors +from schemas.router_schemas import InvestorList +from services.llm_parser import InvestorProcessor from services.querying import QueryProcessor -app = FastAPI() +load_dotenv() init_database() +app = FastAPI() + # Request models class QueryRequest(BaseModel): @@ -20,7 +23,7 @@ class QueryRequest(BaseModel): class Config: json_schema_extra = { "example": { - "question": "Show me growth stage fintech investors in the US with check sizes over $1 million" + "question": "Find me deep tech investors that do deals in Europe under 5 million." } } @@ -31,21 +34,25 @@ def health(): @app.post("/parse-csv", tags=["CSV Upload"], response_model=list[dict]) -async def parse_csv(db: db_dependency, file: UploadFile = File(...)): +async def parse_csv(db: db_dependency, file: UploadFile = File(...), is_investor: int = Form(...)): # Read uploaded CSV with pandas content = await file.read() df = pd.read_csv(io.StringIO(content.decode("utf-8"))) # Process the dataframe - processor = InvestorProcessor(sql_session=db) - results = await processor.process_csv(df) + processor = InvestorProcessor() + + if is_investor == 1: + results = await processor.parse_investors(df) + else: + results = await processor.parse_companies(df) # Convert Pydantic objects to dictionaries return [r.model_dump() for r in results] @app.post("/query", response_model=InvestorList, tags=["Querying"]) -async def query_investors(db: db_dependency, request: QueryRequest): +async def query_investors(request: QueryRequest): """ Query investors using natural language. @@ -55,7 +62,7 @@ async def query_investors(db: db_dependency, request: QueryRequest): - "Growth stage investors with $5M+ check sizes" - "Healthcare investors in Europe" """ - processor = QueryProcessor(sql_session=db) + processor = QueryProcessor() results = processor.process_query(request.question) return results diff --git a/app/pydantic_schemas.py b/app/pydantic_schemas.py deleted file mode 100644 index af54a14..0000000 --- a/app/pydantic_schemas.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import List - -from pydantic import BaseModel - - -class Investor(BaseModel): - name: str - aum: int - check_size: str - sector_focus: str - stage_focus: str - region: str - investment_thesis: str - investor_description: str - - -class InvestorList(BaseModel): - investor_list: List[Investor] - - -class QueryResponse(BaseModel): - name: str - aum: int - check_size: str - sector_focus: str - stage_focus: str - region: str - investment_thesis: str - investor_description: str - reason: str - - -class QueryRequest(BaseModel): - question: str - - -class QueryResponseList(BaseModel): - responses: List[QueryResponse] diff --git a/app/api/__init__.py b/app/routers/__init__.py similarity index 100% rename from app/api/__init__.py rename to app/routers/__init__.py diff --git a/app/routers/__pycache__/__init__.cpython-312.pyc b/app/routers/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..476b0abe804dc321e1181a1337a6802ffa305393 GIT binary patch literal 174 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 X=CxaL6^nJ(6D+XPw#PPrsCfM!O?)qP literal 0 HcmV?d00001 diff --git a/app/api/__pycache__/investors.cpython-312.pyc b/app/routers/__pycache__/investors.cpython-312.pyc similarity index 81% rename from app/api/__pycache__/investors.cpython-312.pyc rename to app/routers/__pycache__/investors.cpython-312.pyc index f8c5b23c1d7cae794f7030c133c88a8256f94617..eeeb4b1a557b73925577ee95122f6f23bdbdcbfa 100644 GIT binary patch delta 663 zcmaFtb=#ZwG%qg~0}$*AznZabBd-ffS`LsmogtMWiZO*DiYb&Kl{u9qh0z8mn!*&t zn#z{SED2--#h5{2sq8=&3!KFYXR(1ZY^KwNAKBw876aPc;K-cVF+@SY$sLwTa?Wfs-RTx=fo>wvPzeoJAD z0=h7j73?Fn6eh6y7^8rGf{THD2~-Dke<~+b9oWA>F)k!Au+M>F+(=^JFaV12K+V%; zNM(Zr2T+g~D98sj5E3wqQTzzalh3fZFbYf-WEW**nXJSfTrUOkUOB{jQgRJ09U2pi zJ9RI!$f5f4hNNtRbBDwPnw~r zHm{U>%fxtp@^r-${9?t)8L7F6#d?rfo1CPyP3Sp;;!_5R`wRy68Eh^y*xYAun5?C& T!eKBW@(Tk8qrv7dWnLx#J42{M delta 569 zcmZ{f%_~Gv7{<@JbMD8?xW4ZAoZ(`8tQcW|MzdfsOtO%LyAZiYL`-ufvtdfga&!_| z+Q?G3%pXCbtK2BFkfpM)u~6Q76gEzsr{{Oh`}RKXW9oTKy;Rizf@6Ia7L(`d2$@{~ zIz$v1*hGdfg{Y4b6UX7z6bzA*&m$_qJNa?qkCV9bx@7KTaEj+lzoAfx703bhvvD!O z_Qe{UWM^W}AdStX({Pt!ORF2yT3^dpUFmE_gDx(mV5|XtThD!$;(5zk-exxLcj6dGA%c}r|nF*k)|6G z%FjEb=^q>IfR%NJbhyoJH>B>U_l~sL3FS(_t#(Q9S;7hXts+CDoHy~5RRmM)LvOTi ztCxNF*dD6CC%9l&>92zH%=?kQ@?4jkNvWS{rP;C))7g5|n@gnMD TC(%}=JwN@96x?oKM^)hqBk+t< diff --git a/app/api/companies.py b/app/routers/companies.py similarity index 72% rename from app/api/companies.py rename to app/routers/companies.py index d74d897..3e83c3a 100644 --- a/app/api/companies.py +++ b/app/routers/companies.py @@ -3,8 +3,8 @@ from typing import List, Optional from db.db import get_db from db.models import CompanyTable, InvestorTable from fastapi import APIRouter, Depends, HTTPException, Query -from py_schemas import CompanySchema from pydantic import BaseModel +from schemas.router_schemas import CompanyData from sqlalchemy.orm import Session, selectinload router = APIRouter(tags=["Company Routes"]) @@ -15,6 +15,7 @@ class CompanyCreate(BaseModel): name: str industry: str location: str + description: Optional[str] = None founded_year: Optional[int] = None website: Optional[str] = None @@ -23,46 +24,33 @@ class CompanyUpdate(BaseModel): name: Optional[str] = None industry: Optional[str] = None location: Optional[str] = None + description: Optional[str] = None founded_year: Optional[int] = None website: Optional[str] = None -# Response schema with relationships -class CompanyData(BaseModel): - """Comprehensive company data schema""" - - company: CompanySchema - investors: List["InvestorBasic"] = [] - - class Config: - from_attributes = True - - -class InvestorBasic(BaseModel): - """Basic investor info for company responses""" - - id: int - name: str - geographic_focus: str - stage_focus: str - check_size_lower: int - check_size_upper: int - - class Config: - from_attributes = True - - @router.get("/companies", response_model=List[CompanyData]) def read_companies(db: Session = Depends(get_db)): """Get all companies with their investor relationships""" companies = ( - db.query(CompanyTable).options(selectinload(CompanyTable.investors)).all() + db.query(CompanyTable) + .options( + selectinload(CompanyTable.investors), + selectinload(CompanyTable.members), + selectinload(CompanyTable.sectors), + ) + .all() ) # Transform CompanyTable objects to CompanyData format company_data_list = [] for company in companies: - company_data = CompanyData(company=company, investors=company.investors) + company_data = CompanyData( + company=company, + investors=company.investors, + members=company.members, + sectors=company.sectors, + ) company_data_list.append(company_data) return company_data_list @@ -89,7 +77,11 @@ def filter_companies( """Filter companies based on various criteria""" # Start with base query - query = db.query(CompanyTable).options(selectinload(CompanyTable.investors)) + query = db.query(CompanyTable).options( + selectinload(CompanyTable.investors), + selectinload(CompanyTable.members), + selectinload(CompanyTable.sectors), + ) # Apply filters if industry: @@ -121,7 +113,12 @@ def filter_companies( # Transform to CompanyData format company_data_list = [] for company in companies: - company_data = CompanyData(company=company, investors=company.investors) + company_data = CompanyData( + company=company, + investors=company.investors, + members=company.members, + sectors=company.sectors, + ) company_data_list.append(company_data) return company_data_list @@ -132,7 +129,11 @@ def read_company(company_id: int, db: Session = Depends(get_db)): """Get a specific company by ID with its investors""" company = ( db.query(CompanyTable) - .options(selectinload(CompanyTable.investors)) + .options( + selectinload(CompanyTable.investors), + selectinload(CompanyTable.members), + selectinload(CompanyTable.sectors), + ) .filter(CompanyTable.id == company_id) .first() ) @@ -141,7 +142,12 @@ def read_company(company_id: int, db: Session = Depends(get_db)): raise HTTPException(status_code=404, detail="Company not found") # Transform to CompanyData format - return CompanyData(company=company, investors=company.investors) + return CompanyData( + company=company, + investors=company.investors, + members=company.members, + sectors=company.sectors, + ) @router.post("/companies", response_model=CompanyData) @@ -155,14 +161,21 @@ def create_company(company: CompanyCreate, db: Session = Depends(get_db)): # Reload with relationships company_with_relations = ( db.query(CompanyTable) - .options(selectinload(CompanyTable.investors)) + .options( + selectinload(CompanyTable.investors), + selectinload(CompanyTable.members), + selectinload(CompanyTable.sectors), + ) .filter(CompanyTable.id == db_company.id) .first() ) # Transform to CompanyData format return CompanyData( - company=company_with_relations, investors=company_with_relations.investors + company=company_with_relations, + investors=company_with_relations.investors, + members=company_with_relations.members, + sectors=company_with_relations.sectors, ) @@ -185,14 +198,21 @@ def update_company( # Reload with relationships company_with_relations = ( db.query(CompanyTable) - .options(selectinload(CompanyTable.investors)) + .options( + selectinload(CompanyTable.investors), + selectinload(CompanyTable.members), + selectinload(CompanyTable.sectors), + ) .filter(CompanyTable.id == company_id) .first() ) # Transform to CompanyData format return CompanyData( - company=company_with_relations, investors=company_with_relations.investors + company=company_with_relations, + investors=company_with_relations.investors, + members=company_with_relations.members, + sectors=company_with_relations.sectors, ) diff --git a/app/api/investors.py b/app/routers/investors.py similarity index 93% rename from app/api/investors.py rename to app/routers/investors.py index f88efbd..d687b8c 100644 --- a/app/api/investors.py +++ b/app/routers/investors.py @@ -1,9 +1,10 @@ + from typing import List, Optional from db.db import get_db from db.models import InvestorTable, SectorTable from fastapi import APIRouter, Depends, HTTPException, Query -from py_schemas import InvestmentStage, InvestorData +from schemas.router_schemas import InvestmentStage, InvestorData from pydantic import BaseModel from sqlalchemy.orm import Session, selectinload @@ -13,7 +14,7 @@ router = APIRouter(tags=["Investor Routes"]) # Request schemas for creating/updating class InvestorCreate(BaseModel): name: str - description: str = None + description: Optional[str] = None aum: int check_size_lower: int check_size_upper: int @@ -23,14 +24,14 @@ class InvestorCreate(BaseModel): class InvestorUpdate(BaseModel): - name: str = None - description: str = None - aum: int = None - check_size_lower: int = None - check_size_upper: int = None - geographic_focus: str = None - stage_focus: InvestmentStage = None - number_of_investments: int = None + name: Optional[str] = None + description: Optional[str] = None + aum: Optional[int] = None + check_size_lower: Optional[int] = None + check_size_upper: Optional[int] = None + geographic_focus: Optional[str] = None + stage_focus: Optional[InvestmentStage] = None + number_of_investments: Optional[int] = None @router.get("/investors", response_model=List[InvestorData]) @@ -230,4 +231,4 @@ def delete_investor(investor_id: int, db: Session = Depends(get_db)): db.delete(db_investor) db.commit() - return {"message": "Investor deleted successfully"} + return {"message": "Investor deleted successfully"} \ No newline at end of file diff --git a/app/services/langgraph_agent.py b/app/schemas/__init__.py similarity index 100% rename from app/services/langgraph_agent.py rename to app/schemas/__init__.py diff --git a/app/schemas/__pycache__/__init__.cpython-312.pyc b/app/schemas/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dd51e01e0423b0005807f32710022e441bd9c2c4 GIT binary patch literal 174 zcmX@j%ge<81gr8cW`O9&AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxd<*T2OpPQ`QdR+N|$LAD@|*SrQ+wS5Wzj!zMRBr8Fniu80+AIwKGlgBTx~85tRin1L(+VFN8_ literal 0 HcmV?d00001 diff --git a/app/schemas/__pycache__/py_schemas.cpython-312.pyc b/app/schemas/__pycache__/py_schemas.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a8e483af428fc605c28e7593e8731d6b12a88cf1 GIT binary patch literal 5067 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 literal 0 HcmV?d00001 diff --git a/app/schemas/py_schemas.py b/app/schemas/py_schemas.py new file mode 100644 index 0000000..5d4d9de --- /dev/null +++ b/app/schemas/py_schemas.py @@ -0,0 +1,111 @@ +from enum import Enum +from typing import List, Optional + +from pydantic import BaseModel, field_validator + + +class InvestmentStage(str, Enum): + SEED = "SEED" + SERIES_A = "SERIES_A" + SERIES_B = "SERIES_B" + SERIES_C = "SERIES_C" + GROWTH = "GROWTH" + LATE_STAGE = "LATE_STAGE" + + +class SectorSchema(BaseModel): + id: int + name: str + + class Config: + from_attributes = True + + +class InvestorMemberSchema(BaseModel): + id: int + name: str + role: str + email: str + investor_id: int + + class Config: + from_attributes = True + + +class CompanyMemberSchema(BaseModel): + id: int + name: Optional[str] = None + linkedin: Optional[str] = None + role: Optional[str] = None + company_id: int + + class Config: + from_attributes = True + + +class CompanySchema(BaseModel): + id: int + name: str + industry: str + location: str + description: Optional[str] = None # Fixed typo from 'nullabel' + founded_year: Optional[int] = None # Changed from str to int to match model + website: Optional[str] = None + + @field_validator("founded_year", mode="before") + @classmethod + def validate_founded_year(cls, v): + if v is None or v == "Not Available" or v == "": + return None + if isinstance(v, str): + try: + return int(v) + except ValueError: + return None + return v + + class Config: + from_attributes = True + + +class InvestorSchema(BaseModel): + id: int + name: str + description: Optional[str] = None + aum: int + check_size_lower: int + check_size_upper: int + geographic_focus: str + stage_focus: InvestmentStage + number_of_investments: int = 0 + + + class Config: + from_attributes = True + + +class InvestorData(BaseModel): + """Comprehensive investor data schema for LLM processing""" + + investor: InvestorSchema + portfolio_companies: List[CompanySchema] = [] + team_members: List[InvestorMemberSchema] = [] # Changed from TeamMember + sectors: List[SectorSchema] = [] + + class Config: + from_attributes = True + + +class CompanyData(BaseModel): # Renamed from CompaniesData for consistency + company: CompanySchema + sectors: List[SectorSchema] = [] + members: List[CompanyMemberSchema] = [] # Changed to match model relationship name + investors: List[InvestorSchema] = [] + + class Config: + from_attributes = True + + +class InvestorList(BaseModel): + investors: List[InvestorData] = [] + diff --git a/app/py_schemas.py b/app/schemas/router_schemas.py similarity index 66% rename from app/py_schemas.py rename to app/schemas/router_schemas.py index baebe92..5e34ee2 100644 --- a/app/py_schemas.py +++ b/app/schemas/router_schemas.py @@ -22,11 +22,31 @@ class SectorSchema(BaseModel): from_attributes = True +class InvestorMemberSchema(BaseModel): + id: int + name: str + role: str + email: str + + class Config: + from_attributes = True + +class CompanyMemberSchema(BaseModel): + id: int + name: Optional[str] = None + linkedin: Optional[str] = None + role: Optional[str] = None + company_id: int + + class Config: + from_attributes = True + class CompanySchema(BaseModel): id: int name: str industry: str location: str + description: Optional[str] founded_year: Optional[int] website: Optional[str] created_at: Optional[datetime] @@ -36,15 +56,6 @@ class CompanySchema(BaseModel): from_attributes = True -class InvestorTeamMemberSchema(BaseModel): - id: int - name: str - role: str - email: str - - class Config: - from_attributes = True - class InvestorSchema(BaseModel): id: int @@ -67,13 +78,22 @@ class InvestorData(BaseModel): """Comprehensive investor data schema for LLM processing""" investor: InvestorSchema - portfolio_companies: List[CompanySchema] = [] - team_members: List[InvestorTeamMemberSchema] = [] - sectors: List[SectorSchema] = [] + portfolio_companies: List[CompanySchema] + team_members: List[InvestorMemberSchema] + sectors: List[SectorSchema] class Config: from_attributes = True +class CompanyData(BaseModel): # Renamed from CompaniesData for consistency + company: CompanySchema + sectors: List[SectorSchema] + members: List[CompanyMemberSchema] + investors: List[InvestorSchema] + + class Config: + from_attributes = True + class InvestorList(BaseModel): - investors: List[InvestorData] + investors: List[InvestorData] \ No newline at end of file diff --git a/app/services/__pycache__/__init__.cpython-312.pyc b/app/services/__pycache__/__init__.cpython-312.pyc index 9191821e8d8caa29167735920660866a2272a897..0bd9599e36d2b529c881d563ecbf2e1b715eb3f3 100644 GIT binary patch delta 31 lcmZ3>xSo;wG%qg~0}!mryEu{Cn9*;dy%BSKnbE`|YXE~;2&w=8 delta 29 jcmZ3_xR#OoG%qg~0}!ZR+%S>bn9*mVy%A&T#1d-&ZjJ}V diff --git a/app/services/__pycache__/langgraph_agent.cpython-312.pyc b/app/services/__pycache__/langgraph_agent.cpython-312.pyc deleted file mode 100644 index 5168061b7e4e3e11bc019fdeaf86c71ac8e8cc2b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7268 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 kzeLn;Ra!#z8-#@w5lx?zCHHO zb!N}UxweD?tw4%Gkg6J~sl+1|RBd0Qy!b6@73oe^d)9`$G!ISR988rI^`YPF-5Nsa zOUK@CzVF*_zS*1k&F`B(XEG@SO@8~2NXPN?;_QA0sn%8JRMXZ;PFjrKEs}WX4IN(%B_e2;@$m z$3gO>&dBSYNo+6@jQGCg&NHKvsw2B!iM9@by@CkhzqTZtMMQ+J#>c@MYQnZAa;+id zV?lDtt4|wc;^;w~5yQ7UH&Dohs;#?vXvA5~adg_4gy4jxxw=C%El6pauF8< zSxvXQZ$#$PpjXp$*Y*4`1=F-uw8cO5!DO-QIi%>>wYtu9*RhJ@o>6m%>$BodJ$kq1vDcNE!;RupEeApp8u5rPnsH z{p(UcsFz=(+#Loj4wikt%8nL`9;`C;YCdGqEttS6E*yp}NX)`oYc*;Iaqwy9NyF&w z8Kgic4$mccwswQ}G{UgfKKF;M(#1ez^S&ZIYIXDq&+=F^u_>7168a7Ltzb$Y3iH^E z`TQq^t)fKQ!~S+xHRTV5JMjpomjKm22Q>UzSP_32i=k!wNS4gQ(UG=w=!vunU&_Z?gyw1Vee~YZ--&gcPfk7f=_kBS1GZ{0&Ke4#oy7?st`!jvq=y)Jl6+w1WJVgCcN1SKomAg@*7ps zb+Qn>t6~rXL=rnlID|2Mp0J>o5?1wG*wQ(VI=WB!7UffcY}gi0Dj8)RQX8IAwFw71 zgIAR301LkOK{iaNg;RHC37+b>7IfP(AufCeUs?7zID>e1YB~VJ;YWzntHUpWuV8#? zRi#hyyh@dN5Y9AsO$*@Rq{5a#e2Kofi0)>0t_)lsXlD<$vIpDQVk=wR=o#J^*w-9A z{mCnzywohb*~(2dv(p><3LiJxCrYgorS%hUw|2d=8ArL%EhOdSuaT6NFOJ_&q4bWn za4c!G|apZp?Q`KElowIz^FB7$ch@&aWr=okg2=SGq&ww6Cv&7fwv)uh0 zw__dF3K=WUz0Y&69C}SU492%G@|{O8q5-AW48KN+NneD(@ABXo5P%gWd+pfOV{K)i zr3|!{11;r1TN!C7BN1X|n%Zo0#%LDIR*p2Yb9Z;`1C*o-fD#UnO#n#Wwc}Th-@sQ- zw3YoWWq(`Ax0HNS%7?^hz%<)!n*hHASC;^{3DU$76}*I&kQwkMI`5{hE^4vwR1 zf+5T!LwE;BYFY5NDRN1O;;swt^-5@2gtyyhsca)6nhT*N@qOUvE)9wu8Z-nb_V5># zX^f^}E)37IQ0KIjdj=gw4A;ST4rm<;GrAz2Lv8%5=`5 zs^$6=t_9&WLIr5+^Hi^v;kf9&8eS?amxYH13aw?R!0raA`j7z5RkcQ|9wVwZ7e<)f zi$Ou1@Krbr3zkV3AK{Y=z6yj6b%U>JXE4-q+j1eG&ZmNWau@;RYksJ^G#M5nGg zM?ELZKIhrCSBINaRCHJ&-$(gs(C>i=WWLF|MLCKo->V=|ZJ1D`EQ9ikW{{0;B!%co zMme)}3Pc3x&YXSg)RZ>%#)NkI)VYupLXd<<7vF2)eJ*DAl)-8Qw_ux6=n& y=>zv;VoJCu-%HE*C-`1Y#?Rl&N%#=NVYwv_ZX&eD5#e4Icg26gJf0YS!+!uf%)BuG diff --git a/app/services/__pycache__/openrouter.cpython-312.pyc b/app/services/__pycache__/openrouter.cpython-312.pyc deleted file mode 100644 index e0e2bde3880ddee85b11e5f46d1f7baeb5458bb5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14261 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 diff --git a/app/services/__pycache__/querying.cpython-312.pyc b/app/services/__pycache__/querying.cpython-312.pyc index ba949b05af0851b9095b8393209181bdb2e12d52..df4cdea36f826bb74ed78789e9256cfe0c1ea1f1 100644 GIT binary patch literal 5346 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 GF8puY-X4Yk literal 10247 zcmbt4YfKzTdfoHvc`$F@U=IcshJ_hp)-Tp;8w0koO04xiWK>hKbzzaz2ju&TDH#8os`>u*f`3a|0Lg6 z-93X@_K{o(s;j=L`s%By?^R#Tzg1Pa8Axya=ReJSw}WARjTJN5jKWrgWtiKHzzD3w zWY`HdW0|n9Shq;l2`hZ9k}YGOuv3^#a%7wnPFl80T*fuwqGgBV&Uhv~wCt3;nW~8@ zTIM8Q#y{bwWtSAl1Sf)-&_syV-BLIco(R*jM~Y;sC#qS-!idhxBK)Qh#V2e-HBWx*%-?*u-!7`5Oc6eb!i%o#=qy~_w;vt^=b!4ivn zhTUQo%{H2r6^%PTr=)XPUK)$BTGdGQswgWta*3amL;zT4<|pA38ow|)#4G$HFN>FQ zIq6DT0jOsi6sqpQ8D2R*CuRpma7c=XydowcPbo=$TFfdir`MbSu~lkF35?-nQBl&_ zX&K61S(L;S)TA6QEVfB}b~-h~r?WkLIw8L!^-zpG@`9|0nVvb4%giZ@HPDb&#QsDg zkv0}0$AwAF0fYklGrPWUr;z3mw`NyzbIB`L?y~+w^s?3Pd@pq)C zV5}Ni1q!?h19PMdPvK^o$!Qd}{tgPa8IhS_1!ltHVqA<66fB}uwADk4U^PoqHo^8T zFri=<9fAX(PHYFL^Ih8n_c>bwHO6`YT4nU|L9Z&P`zZxJ z(dZ@kMIX%PoN@>Om_M+<#zLC!!n{ZpUL(1bD9brArg>mHlQL9*KeT|p@T4%AOi5`t z&6L$xEq)yEkM*r}Dt37`&m<8}lTz-fnBvLI;)oqdU=mj{+{S}YdS@D{s zJTuFg<;wSt-DGzdZ=MLF1kE11j{>OA>{0p7hAU;W^s!NZ5fKws`A)Mad`gW8x#RfQ zSUpfvq1*zsDzlH$Y?fyCn5D{BaNgle1+aY-%->MyWtL`J%u?kmxCG#mG<(OpZD-2+ zvB1Qt#ul4q6lG2Z$;p8Llia)_k~l~jKbKBi?Okk|&gG^hF)=M>(%E#+f%w6mDT$Y7 zdQjY^`)Dehh}kqJ3`kxP7c>r4MRJ}hS0hb;=c1{w*JBM?1ErB16u%sG-Y)N3jyQ8ft?>aLVyR?PKW!7(S3zz|9@xmf$W_K2GesFdV?fb0gd>1LWbmTmXBx(DIr2a%>?B%wwU zrRu&?Xn(1$@u|xmaWC86=bn0*x~@AH*DwA2t)ISizxFu5ybr27N}-zDowqvi(|xPE z80u6*orO@>nox|NRpVzjdPdaH$b)dpQ>U#4@wj2WmR_8%`8UzdV)UpQJ$iTPe)Jp# zl^XUG8xm?mqS$a&Z8%#Bc0LL;Z|uFeX|>e&HtkH~2)o7D8v)Uu`?r96U{Sho%|S%q z;cUvVw%9~fA60*U1C`qhh$aO;pULb3sKwn{X3Gk)G9u5KLce6mTV~O8?MS8~nk_52 zN(ZxT$*Le1)0DT~v6wi=nV5BPKRq(t)Lj@Yn=}v$5;a8)3Q*1P=p)3(e!^-lvqg^C zDSK&7@w%7-g`qj+6ff~a)~viF5ma_D8%e;2^g)IjC(la?ISdsU6+5tFl%T_-h3M8M z(cYK_cd=y$Lw&vrdP4GP$Zjx?{NbX1@2CE~rEu-0g9+6XgDq;Xr5KEJ#aA0(Fc<3`+B4|O6zQc-mU6D0c7CbjRE*Z{{Dkb|INoN%jhtBjoL33Bm)@gJ$ z)eJpW(g}SR+jc$29qRA|^pyVyGUF)QKJ~Z3Q9j_RHhdlTxlTCKaP95Htwb@@p@uq2 z^?O#FrJ7oh({Se&V-35PoliN&(-jG}=B>LOyHM^yLV2se>dqnNyE!&lRo94U~ya8J=F0kJ5vW&dZ?GsWuoLJx493uIZw( zLgrIww}pyYKrxM@$)wgx)@<`&A3pmZNE#D!YK=+YB7&a(R9XUGLQbMroum_yE}%+t zfF8+R5j88=GtHUGWzoiIZRK&MngRPVmD5K9kIm(>vZ%Etr$i++15NsEMFvx)%mBkm zR8Pq>={Y&((AjekCvzH9Xj~bGhfgDi>j=Fjres=>WpqtZH<{Y03Y-%3iFNW9n|3ht zbFHtzSQ*s?m^E<_{TQ5F)9Y+0#Njv1mGH}1pEvNx0z{X39c!~ zyArd6+=^{Gl3>lVyK%f}YfSC}9<>cIe_>BqreV%+0WU7um9mY>+f7crWqIFkgL#2t zEd*@bI8ZSTBUt!fvR`9b8Kvyrw=u-ZGT&g{uerutw|;}U#>Q-mXNGkit8nGWka2w* z7>SyP8;@R^0bdXP_^6!D%t>OD=IHY1W{sd&j4f7Oo=gkz8&oGc_x6+(l{gQY`9 z|D&;g?evF(tBxPNy3yFbJPO%kj)`_J=Qp^%^$=vgf8t`AUjE$!f87&?bp?Ov4QyKB z`%K2m#JimX9_CJHz+wAG3zUB0uw(9V4b)nHQNu$19AA|+E9%SdaiCk-*eHPOuCyW*?JZ$jgHDJ?<()9D*%x5$wl+xNNWBFksFytV7@on5ztH z7TgBRQ-*~FuK}wn!(4)o!t8?Igzca%0MrF{p{`E|;YgVi!t`qsA_n$qz*Ix2Z4qh> zSX~)bE7TjXhBB;5Xf$v%(Kfr#Y_?4^!k)ioQAURtDqA3Jv@iQUm&bkCi!-rImbEcL zRA`YBJhP-0~$M#MwECVX5i_fcpAO}&x>D#C)U0AlBo@JT_1%DA{d@Dug7z;c=6<= zD8`ZI;i{$Eym)FmZ=%jaPj%}B$iC0M3cHBD!M8vQEwOp_J~S6Z{g_iJ`p!Hb;F^f;1KV;COM4eAj+a~*>~n0S4T9AA88hZ!krfX`dZ z_%SI5uC;tTULO7(AW_C>!S8;{{O$(xgmL@bj{??G)bl}o(a{SW3nc+Wl_sQFUkYJz* zoY6l<~4fo)NhjGILH;>=kEv3QtiCzU0BQ89HsL4WWThLT9UvQ&u8*i^2$sO_9rXE zp0VBJEUv+6G;3@oee#r_(R>hXN~UJS)Rm;1UW5pZIYwizq<;(I8gr62aio>cE>O!$ z-k^wxI4AKbk-UX9`xJzbm6)sI50dk=mrlM+z6UkkZaeu*ZXO~bnnN$>tc2PSy+KST z^_$HIfEK&Hz69gJ$OTX~Aghn+TYkLfgFS`%*s`w_iongq!FVCY0P?zNb2j*%+M)E6 zaeLjHHB6vpWsmCbD7D6JI#-;Z2BM{4Ucx7EYrrH;ecpa$BWINW%HgXPxldBRv- zHOr?<{_2%IpTff1x}X!M23mdz+RX`VTVBQ$G8~?g_sl1Iz-`9Sk6{b^lPId)OTC$X{bzG`Rcj2IJ&)PR<>RP1Nemz1aHN~ zm}c&jFNI(pAs#MSGS($3p@AK7~6Km=%N@QDsR#XG`hbgM)aI^B%sl~3yt80 ze=Qn!$LQIGMlZ!U2KyM>K>}2_alx1H;BNc_mthQ8>_}gD*7Ozbi_SXFlEhb@^DfWC zvlwaHkt*S-$~k4jd5au`8G3hghHB**887Iw=;-c9=7np?3E==P?}Q7+NpO8#3In>sM?%H$cR{tSKlrmil){3A5Rgu#Mw z>J5Jz20UZi$)CX&92EZgb_WGKm~4`t&wwif4N#!I#b>CG;c<*JqfA^67 za((*lw0h`tq4~^Bcd4%V$KDUTAAPIXeq3!oey>YyA1&0KyJ>&qZ&;aD{q07F2a%Ry zdW=BS-G`-hK1lH`P}zY#h6|adCX(=%qsBb?}<%LpSZ{@G<&5fY-A42wwf)fDj(ah+f3!k*-Ph_>y8<-sLtO3;+#kJCY(1j3 z9x2ovE!Le>>rR#;dyA1a6<>C`)JWIbg|%zz*C7hF5g)i0+<0-Y5E(Mo@Yv0G>q?RO z+oQKeKWZ+v^r|hr#g>C=%fWT2(1IP`8wO7@(uh-eKJcub`k^287Y2;n8u`mtmtAn; zd!id$L#eLi3FCAfTt54tsk2nyQfliiww+YlPL`V6i_Hhr<^!d=Xc64Xx=wi5Z1ydm zgSc+h-lDf%^|r5O{@wf1rXPks4KabHjTesG_aEKhj?!Rl%rZ6>3*yr%K1(N)nmd^U z!Jn6~>`g)xnwRtzg139kk(-YyP1D`f9tJUiA?>gj##Y_Pi@RX49psnzY9cBnv0W9#Z6 zwV`VZ+cuAI_TDw?CIg>!?rxL1|MV7?HzR&~?ONX^1E2LHcSqH}ku5B5?hD(`v#Y61 z2ES|47X5B^1??~011#|Q#P-Sfzqq!rxcR~xEDRsqWbk`;Xp4R~L5H6hWFK44viAN` zQ*<@_!N{gwIsCF-c;u>nYR6`MnYhAOv*u71=HS^w-yYmf@*-wG#*8XgSNWGm!~sBU zU%8H|JA=3Tg*fQatp45)zW|WwuL88@-8#-Ipjj2r_5^}Tj25W$!QWl*Y5JQ3Ereit z)sN2+adIA>Q1qV_xaSsu4N(jF~5C{ih2K{e_pH+oYd7hEyut@QgdA^WSmu&(MILtGoai1c_Pp zS4_*V8UI73^&wOLkQsQ$9DK-hJY;%*#q>U8nxPCDCfst@@CwCuqUhMN7;s{`l{#a`l`D6OS3r#xITaTwEGeL4--_Y zR)WcW1g8)|L=s3zKnPgy1S0DQBAb!1UGM_S0%ncNLxDYVyxLC_c|Xnq-Ib|@phu{<#1SEpM+6HDbCnC$Z`(JEq73`p9l54lhs2A2ZI^E6n?H8R*`f%eZ zUF*D>Q6ZJe)fttDV40R@Pg_-{_G7{O@y=_f&GagQkk#a*=rBV8|Qk}jscgr73z2+pqGe&@=0#L{%t_3HAn3Cuv{6*Ssb znba*xL(WAOX0hOk(wr=thLoRe8DvJC8#4~IfaVy^ZY_Ovmi{=Tw)h*+6m-Kh_i}A8 zim-#YN2^k$c?WqC?f%=F)tS1>#TBl|n@BcOQFqY%vI!yI;qFD Dict[str, Any]: - """Safely parse JSON string with LLM assistance if needed""" - if not json_str or json_str.strip() == "": - return {} + def _get_or_create_sector(self, db: Session, sector_name: str) -> SectorTable: + """Get existing sector or create new one""" + sector = db.query(SectorTable).filter(SectorTable.name == sector_name).first() + if not sector: + sector = SectorTable(name=sector_name) + db.add(sector) + db.flush() # Get the ID without committing + return sector - try: - # Try direct JSON parsing first - return json.loads(json_str) - except json.JSONDecodeError: - # If direct parsing fails, use LLM to clean and parse - logger.info("Direct JSON parsing failed, using LLM to clean JSON") - return self._llm_clean_json(json_str) + def _save_investor_to_db( + self, db: Session, investor_data: InvestorData + ) -> InvestorTable: + """Save investor data to database""" + # Create investor record + investor = InvestorTable( + name=investor_data.investor.name, + description=investor_data.investor.description, + aum=investor_data.investor.aum, + check_size_lower=investor_data.investor.check_size_lower, + check_size_upper=investor_data.investor.check_size_upper, + geographic_focus=investor_data.investor.geographic_focus, + stage_focus=investor_data.investor.stage_focus, + number_of_investments=investor_data.investor.number_of_investments, + ) + db.add(investor) + db.flush() # Get the ID - def _llm_clean_json(self, malformed_json: str) -> Dict[str, Any]: - """Use LLM to clean and parse malformed JSON""" - try: - prompt = f""" - The following text appears to be malformed JSON. Please clean it up and return valid JSON. - If it's not possible to create valid JSON, return an empty object {{}}. - - Original text: - {malformed_json[:2000]} # Limit length for API - - Return only the cleaned JSON, no explanations: - """ - - response = self.openai_client.chat.completions.create( - model="gpt-3.5-turbo", - messages=[{"role": "user", "content": prompt}], - temperature=0, + # Add team members + for member_data in investor_data.team_members: + member = InvestorMember( + name=member_data.name, + role=member_data.role, + email=member_data.email, + investor_id=investor.id, ) + db.add(member) - cleaned_json = response.choices[0].message.content.strip() - return json.loads(cleaned_json) + # Add sectors + for sector_data in investor_data.sectors: + sector = self._get_or_create_sector(db, sector_data.name) + investor.sectors.append(sector) - except Exception as e: - logger.error(f"LLM JSON cleaning failed: {e}") - return {} - - def extract_structured_data(self, csv_row: CSVRow) -> Dict[str, Any]: - """Extract and structure data from CSV row using LLM""" - # Parse the investment firm profile - profile_data = {} - if csv_row.investment_firm_profile: - profile_data = self.parse_json_field(csv_row.investment_firm_profile) - - # Create structured output - structured_data = { - "name": csv_row.name, - "website": csv_row.website or profile_data.get("websiteURL"), - "investor_description": profile_data.get("investorDescription", ""), - "investment_thesis_focus": profile_data.get("investmentThesisFocus", []), - "headquarters": profile_data.get("headquarters", ""), - "aum_info": profile_data.get("overallAssetsUnderManagement", {}), - "funds_info": profile_data.get("funds", []), - "crunchbase_urls": csv_row.crunchbase_linkedin_urls or "", - "crunchbase_extract": csv_row.crunchbase_firm_extract or "", - "linkedin_profile": csv_row.linkedin_investment_profile or "", - "source_truth_profile": csv_row.source_of_truth_profile or "", - } - - return structured_data - - def enhance_with_llm(self, investor_data: Dict[str, Any]) -> Dict[str, Any]: - """Use LLM to enhance and standardize investor data""" - try: - # Combine all available text for context - context_text = " ".join( - [ - investor_data.get("investor_description", ""), - investor_data.get("crunchbase_extract", ""), - investor_data.get("linkedin_profile", ""), - investor_data.get("source_truth_profile", ""), - ] + # Add portfolio companies + for company_schema in investor_data.portfolio_companies: + # Convert CompanySchema to CompanyData format + company_data = CompanyData( + company=company_schema, + sectors=[], # Will be empty for portfolio companies + members=[], # Will be empty for portfolio companies + investors=[], # Will be empty for portfolio companies ) + company = self._save_company_to_db(db, company_data, skip_investors=True) + investor.portfolio_companies.append(company) - if not context_text.strip(): - return investor_data + return investor - prompt = f""" - Based on the following information about an investor, please extract and standardize: - 1. A concise investor description (2-3 sentences) - 2. Investment thesis focus areas (list of specific focus areas) - 3. Headquarters location (city, country format) - - Investor: {investor_data["name"]} - Context: {context_text[:3000]} # Limit for API - - Return in JSON format: - {{ - "enhanced_description": "concise description here", - "standardized_focus": ["focus area 1", "focus area 2", ...], - "standardized_headquarters": "City, Country" - }} - """ + def _save_company_to_db( + self, db: Session, company_data: CompanyData, skip_investors: bool = False + ) -> CompanyTable: + """Save company data to database""" + # Check if company already exists + existing_company = ( + db.query(CompanyTable) + .filter(CompanyTable.name == company_data.company.name) + .first() + ) + if existing_company: + return existing_company - response = self.openai_client.chat.completions.create( - model="gpt-3.5-turbo", - messages=[{"role": "user", "content": prompt}], - temperature=0.3, - ) + # Create company record + company = CompanyTable( + name=company_data.company.name, + industry=company_data.company.industry, + location=company_data.company.location, + description=company_data.company.description, + founded_year=company_data.company.founded_year, + website=company_data.company.website, + ) + db.add(company) + db.flush() # Get the ID - enhanced_data = json.loads(response.choices[0].message.content) + # Add company members + for member_data in company_data.members: + if member_data.name: # Only add members with names + member = CompanyMember( + name=member_data.name, + linkedin=member_data.linkedin, + role=member_data.role, + company_id=company.id, + ) + db.add(member) - # Update investor data with enhanced information - if enhanced_data.get("enhanced_description"): - investor_data["enhanced_description"] = enhanced_data[ - "enhanced_description" - ] + # Add sectors + for sector_data in company_data.sectors: + sector = self._get_or_create_sector(db, sector_data.name) + company.sectors.append(sector) - if enhanced_data.get("standardized_focus"): - investor_data["standardized_focus"] = enhanced_data[ - "standardized_focus" - ] - - if enhanced_data.get("standardized_headquarters"): - investor_data["standardized_headquarters"] = enhanced_data[ - "standardized_headquarters" - ] - - return investor_data - - except Exception as e: - logger.error(f"LLM enhancement failed for {investor_data['name']}: {e}") - return investor_data - - def save_to_sql(self, investor_data: Dict[str, Any]) -> int: - """Save investor data to SQL database""" - try: - with get_session() as session: - # Check if investor already exists - existing = ( - session.query(Investor) - .filter_by(name=investor_data["name"]) + # Add investors (if not skipping to avoid circular references) + if not skip_investors: + for investor_data in company_data.investors: + # Look for existing investor by name + existing_investor = ( + db.query(InvestorTable) + .filter(InvestorTable.name == investor_data.name) .first() ) + if existing_investor: + company.investors.append(existing_investor) - if existing: - logger.info(f"Updating existing investor: {investor_data['name']}") - investor = existing - else: - logger.info(f"Creating new investor: {investor_data['name']}") - investor = Investor() + return company - # Map data to investor object - investor.name = investor_data["name"] - investor.website = investor_data.get("website") - investor.investor_description = investor_data.get( - "enhanced_description" - ) or investor_data.get("investor_description") - investor.investment_thesis_focus = investor_data.get( - "standardized_focus" - ) or investor_data.get("investment_thesis_focus") - investor.headquarters = investor_data.get( - "standardized_headquarters" - ) or investor_data.get("headquarters") - - # AUM information - aum_info = investor_data.get("aum_info", {}) - investor.aum_amount = aum_info.get("aumAmount") - investor.aum_as_of_date = aum_info.get("asOfDate") - investor.aum_source_url = aum_info.get("sourceUrl") - - # Fund information - investor.funds_info = investor_data.get("funds_info", []) - - # Raw data - investor.crunchbase_urls = investor_data.get("crunchbase_urls") - investor.crunchbase_extract = investor_data.get("crunchbase_extract") - investor.linkedin_profile = investor_data.get("linkedin_profile") - investor.source_truth_profile = investor_data.get( - "source_truth_profile" + async def _process_row( + self, row: pd.Series, row_idx: int, is_investor: bool = True + ) -> Optional[InvestorData | CompanyData]: + """Process a single row of data""" + # Clean values to remove control characters + cleaned_row = {} + for key, value in row.items(): + if pd.notna(value): + # Convert to string and clean control characters + clean_value = ( + str(value).replace("\n", " ").replace("\r", " ").replace("\t", " ") ) + # Remove other control characters + clean_value = "".join( + char + for char in clean_value + if ord(char) >= 32 or char in ["\n", "\r", "\t"] + ) + cleaned_row[key] = clean_value - if not existing: - session.add(investor) - - session.flush() # Get the ID - return investor.id - - except Exception as e: - logger.error(f"Failed to save to SQL: {e}") - raise - - def save_to_vector_db(self, investor_id: int, investor_data: Dict[str, Any]): - """Save investor description and focus to ChromaDB""" + row_str = ", ".join([f"{key}: {value}" for key, value in cleaned_row.items()]) try: - # Prepare text for embedding - description_text = investor_data.get( - "enhanced_description" - ) or investor_data.get("investor_description", "") - focus_areas = investor_data.get("standardized_focus") or investor_data.get( - "investment_thesis_focus", [] - ) - - if isinstance(focus_areas, list): - focus_text = " ".join(focus_areas) + print(f"Processing row {row_idx + 1}...") + if is_investor: + result = await self.investor_structured_llm.ainvoke(row_str) else: - focus_text = str(focus_areas) - - # Combine description and focus for embedding - combined_text = f"{description_text} {focus_text}".strip() - - if not combined_text: - logger.warning(f"No text to embed for investor {investor_data['name']}") - return - - # Create metadata - metadata = { - "investor_id": investor_id, - "name": investor_data["name"], - "website": investor_data.get("website", ""), - "headquarters": investor_data.get("standardized_headquarters") - or investor_data.get("headquarters", ""), - "focus_areas_count": len(focus_areas) - if isinstance(focus_areas, list) - else 0, - } - - # Add to ChromaDB - self.collection.add( - documents=[combined_text], - metadatas=[metadata], - ids=[f"investor_{investor_id}"], - ) - - logger.info(f"Added investor {investor_data['name']} to vector database") - + result = await self.company_structured_llm.ainvoke(row_str) + if result: + return result.model_dump() + return None except Exception as e: - logger.error(f"Failed to save to vector DB: {e}") - - def process_csv_file(self, csv_file_path: str, limit: Optional[int] = None): - """Process the entire CSV file""" - logger.info(f"Starting to process CSV file: {csv_file_path}") - - # Read CSV - df = pd.read_csv(csv_file_path) - logger.info(f"Loaded {len(df)} rows from CSV") - - if limit: - df = df.head(limit) - logger.info(f"Processing limited to {limit} rows") - - processed_count = 0 - error_count = 0 - - for index, row in df.iterrows(): - try: - logger.info(f"Processing row {index + 1}/{len(df)}: {row['Name']}") - - # Create CSVRow object - csv_row = CSVRow( - name=row["Name"], - website=row.get("Website"), - investment_firm_profile=row.get("Investment Firm Profile"), - crunchbase_linkedin_urls=row.get("Crunchbase & LinkedIn URLs"), - crunchbase_firm_extract=row.get("Crunchbase Firm Extract"), - linkedin_investment_profile=row.get("LinkedIn Investment Profile"), - source_of_truth_profile=row.get("Source of Truth Profile"), - ) - - # Extract structured data - structured_data = self.extract_structured_data(csv_row) - - # Enhance with LLM - enhanced_data = self.enhance_with_llm(structured_data) - - # Save to SQL database - investor_id = self.save_to_sql(enhanced_data) - - # Save to vector database - self.save_to_vector_db(investor_id, enhanced_data) - - processed_count += 1 - - # Progress update every 10 rows - if (index + 1) % 10 == 0: - logger.info( - f"Processed {processed_count} rows successfully, {error_count} errors" - ) - - except Exception as e: - error_count += 1 - logger.error( - f"Error processing row {index + 1} ({row.get('Name', 'Unknown')}): {e}" - ) - continue - - logger.info( - f"Processing complete! Processed: {processed_count}, Errors: {error_count}" - ) - return processed_count, error_count - - def search_investors(self, query: str, limit: int = 5): - """Search investors using vector similarity""" - try: - results = self.collection.query(query_texts=[query], n_results=limit) - - return results - - except Exception as e: - logger.error(f"Search failed: {e}") + print(f"Error processing row {row_idx + 1}: {e}") return None + async def parse_investors(self, df, save_to_db: bool = True): + """Parse investors from DataFrame and optionally save to database""" + investors = [] -def main(): - """Main function to run the parser""" - parser = LLMInvestorParser() + db = None + if save_to_db: + db = get_db_session() - # Process the CSV file - csv_file = "/home/oluwasanmi/Documents/Work/MKD/anton_wireframe/New Excerpt 5 investors - Sheet1 parse.csv" + try: + # Process rows in batches asynchronously + batch_size = 15 # Adjust batch size as needed + rows = [(idx, row) for idx, row in df.iterrows()] - # Start with a small sample for testing - processed, errors = parser.process_csv_file(csv_file, limit=5) + for i in range(0, len(rows), batch_size): + batch = rows[i : i + batch_size] - print("\nProcessing complete!") - print(f"Successfully processed: {processed} investors") - print(f"Errors encountered: {errors}") + # Process batch asynchronously + tasks = [ + self._process_row(row, idx, is_investor=True) for idx, row in batch + ] - # Test search functionality - print("\nTesting search functionality...") - results = parser.search_investors("bioeconomy circular economy") - if results: - print(f"Found {len(results['documents'][0])} similar investors") - for i, doc in enumerate(results["documents"][0]): - print(f" {i + 1}. {results['metadatas'][0][i]['name']}") + batch_results = await asyncio.gather(*tasks, return_exceptions=True) + + # Handle results from batch + for (idx, row), result in zip(batch, batch_results): + if isinstance(result, Exception): + print(f"Error processing row {idx}: {result}") + if db: + db.rollback() + continue + + if result: + # Convert dict to InvestorData if needed + if isinstance(result, dict): + investor_data = InvestorData(**result) + else: + investor_data = result + + investors.append(investor_data) + + # Save to database if requested + if save_to_db and db: + try: + saved_investor = self._save_investor_to_db( + db, investor_data + ) + db.commit() + print( + f"✅ Saved investor '{saved_investor.name}' to database" + ) + except Exception as e: + db.rollback() + print(f"❌ Failed to save investor to database: {e}") + + print( + f"Completed batch {i // batch_size + 1} of {(len(rows) + batch_size - 1) // batch_size}" + ) + + except Exception as e: + print(f"Error in batch processing: {e}") + if db: + db.rollback() + finally: + if db: + db.close() + + return investors + + async def parse_companies(self, df, save_to_db: bool = True): + """Parse companies from DataFrame and optionally save to database""" + companies = [] + + db = None + if save_to_db: + db = get_db_session() + + try: + # Process rows in batches asynchronously + batch_size = 15 # Adjust batch size as needed + rows = [(idx, row) for idx, row in df.iterrows()] + + for i in range(0, len(rows), batch_size): + batch = rows[i : i + batch_size] + + # Process batch asynchronously + tasks = [ + self._process_row(row, idx, is_investor=False) for idx, row in batch + ] + + batch_results = await asyncio.gather(*tasks, return_exceptions=True) + + # Handle results from batch + for (idx, row), result in zip(batch, batch_results): + if isinstance(result, Exception): + print(f"Error processing row {idx}: {result}") + if db: + db.rollback() + continue + + if result: + # Convert dict to CompanyData if needed + if isinstance(result, dict): + company_data = CompanyData(**result) + else: + company_data = result + + companies.append(company_data) + + # Save to database if requested + if save_to_db and db: + try: + saved_company = self._save_company_to_db( + db, company_data + ) + db.commit() + print( + f"✅ Saved company '{saved_company.name}' to database" + ) + except Exception as e: + db.rollback() + print(f"❌ Failed to save company to database: {e}") + + print( + f"Completed batch {i // batch_size + 1} of {(len(rows) + batch_size - 1) // batch_size}" + ) + + except Exception as e: + print(f"Error processing row {idx}: {e}") + if db: + db.rollback() + finally: + if db: + db.close() + + return companies -if __name__ == "__main__": - main() \ No newline at end of file +# async def main(): +# """Main execution function""" +# # Initialize database tables +# print("🔧 Initializing database...") +# init_database() + +# # Create processor +# processor = InvestorProcessor() + +# print("📊 Processing companies...") +# companies = await processor.parse_companies( +# "data/19 Companies data.csv", save_to_db=True +# ) +# print(f"Processed {len(companies)} companies") + +# print("\n💰 Processing investors...") +# investors = await processor.parse_investors( +# "data/19 Investors data.csv", save_to_db=True +# ) +# print(f"Processed {len(investors)} investors") +# print("\n✨ Processing complete!") + + +# if __name__ == "__main__": +# asyncio.run(main()) diff --git a/app/services/openrouter.py b/app/services/openrouter.py deleted file mode 100644 index 6b7481d..0000000 --- a/app/services/openrouter.py +++ /dev/null @@ -1,293 +0,0 @@ -import asyncio -from typing import List, Optional - -import chromadb -import pandas as pd -from db.models import CompanyTable, InvestorTable, InvestorTeamMember, SectorTable -from langchain_core.prompts import PromptTemplate -from langchain_openai import ChatOpenAI -from py_schemas import InvestorData -from pydantic import BaseModel -from settings import settings - - -class InvestorList(BaseModel): - """Schema for LLM structured output""" - - investor_list: List[InvestorData] - - -class InvestorProcessor: - def __init__( - self, - sql_session: Optional[object] = None, - vector_db_client: Optional[object] = None, - ): - self.template = """You are an expert data extraction assistant. Extract investor information from the provided CSV data and return it as a list of structured records. - -Given the following CSV data rows: -{question} - -For each row, extract and structure the following fields for the investor: -- name: The investor's full name -- description: Description of the investor -- aum: Assets under management (as integer, use 0 if not available) -- check_size_lower: Lower bound of investment check size (as integer) -- check_size_upper: Upper bound of investment check size (as integer) -- geographic_focus: Geographic region focus -- stage_focus: Investment stage focus (must be one of: seed, series_a, series_b, series_c, growth, late_stage) -- number_of_investments: Number of investments made (default 0) - -Also extract related data: -- portfolio_companies: List of companies they've invested in -- team_members: List of team members with name, role, email -- sectors: List of sectors they focus on - -Important: -- If a field is not available, use appropriate defaults -- stage_focus must be one of the valid enum values -- Return clean, valid JSON only - -Return the data as a structured list of comprehensive investor data.""" - - self.prompt = PromptTemplate( - template=self.template, input_variables=["question"] - ) - - self.llm = ChatOpenAI( - api_key=settings.OPENROUTER_API_KEY, - base_url="https://openrouter.ai/api/v1", - model="google/gemini-2.5-flash-lite", - temperature=0, - ) - - self.structured_llm = self.llm.with_structured_output(InvestorList) - self.sql_session = sql_session - self.vector_db_client = vector_db_client - - self.vector_db_client = chromadb.PersistentClient(path="./chroma_db") - self.collection = self.vector_db_client.get_or_create_collection( - name="investor_descriptions", - metadata={ - "description": "Investor descriptions and investment thesis focus" - }, - ) - - async def _process_batch( - self, batch: pd.DataFrame, batch_idx: int - ) -> List[InvestorData]: - """Process a single batch of data""" - # Convert batch to string representation - clean the data - batch_str = "" - for idx, row in batch.iterrows(): - # Clean values to remove control characters - cleaned_row = {} - for key, value in row.items(): - if pd.notna(value): - # Convert to string and clean control characters - clean_value = ( - str(value) - .replace("\n", " ") - .replace("\r", " ") - .replace("\t", " ") - ) - # Remove other control characters - clean_value = "".join( - char - for char in clean_value - if ord(char) >= 32 or char in ["\n", "\r", "\t"] - ) - cleaned_row[key] = clean_value - - row_str = ", ".join( - [f"{key}: {value}" for key, value in cleaned_row.items()] - ) - batch_str += f"Row {idx + 1}: {row_str}\n" - - try: - print(f"Processing batch {batch_idx + 1}...") - batch_results = await self.structured_llm.ainvoke(batch_str) - return batch_results.investor_list - except Exception as e: - print(f"Error processing batch {batch_idx + 1}: {e}") - return [] - - async def _save_to_sql(self, investor_data_list: List[InvestorData]) -> None: - """Save investors and related data to SQL database""" - if not self.sql_session: - return - - try: - for investor_data in investor_data_list: - # Save investor - db_investor = InvestorTable( - name=investor_data.investor.name, - description=investor_data.investor.description, - aum=investor_data.investor.aum, - check_size_lower=investor_data.investor.check_size_lower, - check_size_upper=investor_data.investor.check_size_upper, - geographic_focus=investor_data.investor.geographic_focus, - stage_focus=investor_data.investor.stage_focus, - number_of_investments=investor_data.investor.number_of_investments, - ) - self.sql_session.add(db_investor) - self.sql_session.flush() # Get the ID - - # Save sectors and create associations - for sector_data in investor_data.sectors: - # Check if sector exists, create if not - existing_sector = ( - self.sql_session.query(SectorTable) - .filter(SectorTable.name == sector_data.name) - .first() - ) - - if not existing_sector: - db_sector = SectorTable(name=sector_data.name) - self.sql_session.add(db_sector) - self.sql_session.flush() - # Add sector to investor's sectors - db_investor.sectors.append(db_sector) - else: - # Add existing sector to investor if not already there - if existing_sector not in db_investor.sectors: - db_investor.sectors.append(existing_sector) - - # Save companies and create portfolio associations - for company_data in investor_data.portfolio_companies: - # Check if company exists, create if not - existing_company = ( - self.sql_session.query(CompanyTable) - .filter(CompanyTable.name == company_data.name) - .first() - ) - - if not existing_company: - db_company = CompanyTable( - name=company_data.name, - industry=company_data.industry, - location=company_data.location, - founded_year=company_data.founded_year, - website=company_data.website, - ) - self.sql_session.add(db_company) - self.sql_session.flush() - - # Add to investor's portfolio - db_investor.portfolio_companies.append(db_company) - else: - # Add existing company to portfolio if not already there - if existing_company not in db_investor.portfolio_companies: - db_investor.portfolio_companies.append(existing_company) - - # Save team members - for team_member_data in investor_data.team_members: - # Check if team member exists - existing_member = ( - self.sql_session.query(InvestorTeamMember) - .filter(InvestorTeamMember.email == team_member_data.email) - .first() - ) - - if not existing_member: - db_team_member = InvestorTeamMember( - name=team_member_data.name, - role=team_member_data.role, - email=team_member_data.email, - investor_id=db_investor.id, - ) - self.sql_session.add(db_team_member) - - self.sql_session.commit() - print(f"Successfully saved {len(investor_data_list)} investors to database") - - except Exception as e: - self.sql_session.rollback() - print(f"Error saving to SQL database: {e}") - raise - - async def _save_to_vector_db(self, investor_data_list: List[InvestorData]) -> None: - """Save investors to vector database""" - if not self.vector_db_client: - return - - documents = [] - metadatas = [] - ids = [] - - for i, investor_data in enumerate(investor_data_list): - investor = investor_data.investor - sectors = ", ".join([s.name for s in investor_data.sectors]) - companies = ", ".join([c.name for c in investor_data.portfolio_companies]) - - doc_text = f""" - Investor: {investor.name} - Description: {investor.description or "N/A"} - AUM: ${investor.aum:,} - Check Size: ${investor.check_size_lower:,} - ${investor.check_size_upper:,} - Geographic Focus: {investor.geographic_focus} - Stage Focus: {investor.stage_focus.value} - Sectors: {sectors} - Portfolio Companies: {companies} - """.strip() - - documents.append(doc_text) - metadatas.append( - { - "name": investor.name, - "stage_focus": investor.stage_focus.value, - "geographic_focus": investor.geographic_focus, - "aum": investor.aum, - } - ) - ids.append( - f"investor_{i}_{investor.name.replace(' ', '_').replace('/', '_')}" - ) - - if documents: - try: - self.collection.add(documents=documents, metadatas=metadatas, ids=ids) - print( - f"Successfully saved {len(documents)} investors to vector database" - ) - except Exception as e: - print(f"Error saving to vector database: {e}") - - async def process_csv( - self, df: pd.DataFrame, batch_size: int = 10, max_concurrent: int = 10 - ) -> List[InvestorData]: - """Process CSV data in parallel batches and save to databases""" - results = [] - - # Create batches - batches = [] - for i in range(0, len(df), batch_size): - batch = df.iloc[i : i + batch_size] - batches.append((batch, i // batch_size)) - - # Process batches with concurrency control - semaphore = asyncio.Semaphore(max_concurrent) - - async def process_with_semaphore(batch_data): - batch, batch_idx = batch_data - async with semaphore: - return await self._process_batch(batch, batch_idx) - - # Execute all batches concurrently - batch_results = await asyncio.gather( - *[process_with_semaphore(batch_data) for batch_data in batches], - return_exceptions=True, - ) - - # Collect results, filtering out exceptions - for batch_result in batch_results: - if not isinstance(batch_result, Exception): - results.extend(batch_result) - - # Save to databases - if results: - print(f"Successfully processed {len(results)} investors") - await self._save_to_sql(results) - await self._save_to_vector_db(results) - - return results diff --git a/app/services/openrouter_v2.py b/app/services/openrouter_v2.py deleted file mode 100644 index d37120d..0000000 --- a/app/services/openrouter_v2.py +++ /dev/null @@ -1,290 +0,0 @@ -import asyncio -from typing import List, Optional - -import chromadb -import pandas as pd -from db.models import CompanyTable, InvestorTable, InvestorTeamMember, SectorTable -from langchain_core.prompts import PromptTemplate -from langchain_openai import ChatOpenAI -from py_schemas import InvestorData -from pydantic import BaseModel -from settings import settings - - -class InvestorOutput(BaseModel): - """Schema for LLM structured output""" - - investor_data: InvestorData - - -class InvestorProcessor: - def __init__( - self, - sql_session: Optional[object] = None, - vector_db_client: Optional[object] = None, - ): - self.template = """You are an expert data extraction assistant. Extract investor information from the provided CSV data and return it as a structured record. - -Given the following CSV data row: -{question} - -Extract and structure the following fields for the investor: -- name: The investor's full name -- description: Description of the investor -- aum: Assets under management (as integer, use 0 if not available) -- check_size_lower: Lower bound of investment check size (as integer) -- check_size_upper: Upper bound of investment check size (as integer) -- geographic_focus: Geographic region focus -- stage_focus: Investment stage focus (must be one of: seed, series_a, series_b, series_c, growth, late_stage) -- number_of_investments: Number of investments made (default 0) - -Also extract related data: -- portfolio_companies: List of companies they've invested in -- team_members: List of team members with name, role, email -- sectors: List of sectors they focus on - -Important: -- If a field is not available, use appropriate defaults -- stage_focus must be one of the valid enum values -- Return clean, valid JSON only - -Return the data as a single comprehensive investor data record.""" - - self.prompt = PromptTemplate( - template=self.template, input_variables=["question"] - ) - - self.llm = ChatOpenAI( - api_key=settings.OPENROUTER_API_KEY, - base_url="https://openrouter.ai/api/v1", - model="google/gemini-2.5-flash-lite", - temperature=0, - ) - - self.structured_llm = self.llm.with_structured_output(InvestorOutput) - self.sql_session = sql_session - self.vector_db_client = vector_db_client - - self.vector_db_client = chromadb.PersistentClient(path="./chroma_db") - self.collection = self.vector_db_client.get_or_create_collection( - name="investor_descriptions", - metadata={ - "description": "Investor descriptions and investment thesis focus" - }, - ) - - async def _process_row( - self, row: pd.Series, row_idx: int - ) -> Optional[InvestorData]: - """Process a single row of data""" - # Clean values to remove control characters - cleaned_row = {} - for key, value in row.items(): - if pd.notna(value): - # Convert to string and clean control characters - clean_value = ( - str(value) - .replace("\n", " ") - .replace("\r", " ") - .replace("\t", " ") - ) - # Remove other control characters - clean_value = "".join( - char - for char in clean_value - if ord(char) >= 32 or char in ["\n", "\r", "\t"] - ) - cleaned_row[key] = clean_value - - row_str = ", ".join( - [f"{key}: {value}" for key, value in cleaned_row.items()] - ) - - try: - print(f"Processing row {row_idx + 1}...") - result = await self.structured_llm.ainvoke(row_str) - if result.investor_data: - return result.investor_data - return None - except Exception as e: - print(f"Error processing row {row_idx + 1}: {e}") - return None - - async def _save_to_sql(self, investor_data_list: List[InvestorData]) -> None: - """Save investors and related data to SQL database""" - if not self.sql_session: - return - - try: - for investor_data in investor_data_list: - # Save investor - db_investor = InvestorTable( - name=investor_data.investor.name, - description=investor_data.investor.description, - aum=investor_data.investor.aum, - check_size_lower=investor_data.investor.check_size_lower, - check_size_upper=investor_data.investor.check_size_upper, - geographic_focus=investor_data.investor.geographic_focus, - stage_focus=investor_data.investor.stage_focus, - number_of_investments=investor_data.investor.number_of_investments, - ) - self.sql_session.add(db_investor) - self.sql_session.flush() # Get the ID - - # Save sectors and create associations - for sector_data in investor_data.sectors: - # Check if sector exists, create if not - existing_sector = ( - self.sql_session.query(SectorTable) - .filter(SectorTable.name == sector_data.name) - .first() - ) - - if not existing_sector: - db_sector = SectorTable(name=sector_data.name) - self.sql_session.add(db_sector) - self.sql_session.flush() - # Add sector to investor's sectors - db_investor.sectors.append(db_sector) - else: - # Add existing sector to investor if not already there - if existing_sector not in db_investor.sectors: - db_investor.sectors.append(existing_sector) - - # Save companies and create portfolio associations - for company_data in investor_data.portfolio_companies: - # Check if company exists, create if not - existing_company = ( - self.sql_session.query(CompanyTable) - .filter(CompanyTable.name == company_data.name) - .first() - ) - - if not existing_company: - db_company = CompanyTable( - name=company_data.name, - industry=company_data.industry, - location=company_data.location, - founded_year=company_data.founded_year, - website=company_data.website, - ) - self.sql_session.add(db_company) - self.sql_session.flush() - - # Add to investor's portfolio - db_investor.portfolio_companies.append(db_company) - else: - # Add existing company to portfolio if not already there - if existing_company not in db_investor.portfolio_companies: - db_investor.portfolio_companies.append(existing_company) - - # Save team members - for team_member_data in investor_data.team_members: - # Check if team member exists - existing_member = ( - self.sql_session.query(InvestorTeamMember) - .filter(InvestorTeamMember.email == team_member_data.email) - .first() - ) - - if not existing_member: - db_team_member = InvestorTeamMember( - name=team_member_data.name, - role=team_member_data.role, - email=team_member_data.email, - investor_id=db_investor.id, - ) - self.sql_session.add(db_team_member) - - self.sql_session.commit() - print(f"Successfully saved {len(investor_data_list)} investors to database") - - except Exception as e: - self.sql_session.rollback() - print(f"Error saving to SQL database: {e}") - raise - - async def _save_to_vector_db(self, investor_data_list: List[InvestorData]) -> None: - """Save investors to vector database""" - if not self.vector_db_client: - return - - documents = [] - metadatas = [] - ids = [] - - for i, investor_data in enumerate(investor_data_list): - investor = investor_data.investor - sectors = ", ".join([s.name for s in investor_data.sectors]) - companies = ", ".join([c.name for c in investor_data.portfolio_companies]) - - doc_text = f""" - Investor: {investor.name} - Description: {investor.description or "N/A"} - AUM: ${investor.aum:,} - Check Size: ${investor.check_size_lower:,} - ${investor.check_size_upper:,} - Geographic Focus: {investor.geographic_focus} - Stage Focus: {investor.stage_focus.value} - Sectors: {sectors} - Portfolio Companies: {companies} - """.strip() - - documents.append(doc_text) - metadatas.append( - { - "name": investor.name, - "stage_focus": investor.stage_focus.value, - "geographic_focus": investor.geographic_focus, - "aum": investor.aum, - } - ) - ids.append( - f"investor_{i}_{investor.name.replace(' ', '_').replace('/', '_')}" - ) - - if documents: - try: - self.collection.add(documents=documents, metadatas=metadatas, ids=ids) - print( - f"Successfully saved {len(documents)} investors to vector database" - ) - except Exception as e: - print(f"Error saving to vector database: {e}") - - async def process_csv( - self, df: pd.DataFrame, max_concurrent: int = 10 - ) -> List[InvestorData]: - """Process CSV data one row at a time and save to databases""" - results = [] - - # Create semaphore for concurrency control - semaphore = asyncio.Semaphore(max_concurrent) - - async def process_row_with_semaphore(row_data): - row, row_idx = row_data - async with semaphore: - return await self._process_row(row, row_idx) - - # Create row tasks - row_tasks = [] - for idx, row in df.iterrows(): - row_tasks.append((row, idx)) - - # Execute all rows concurrently - row_results = await asyncio.gather( - *[process_row_with_semaphore(row_data) for row_data in row_tasks], - return_exceptions=True, - ) - - # Collect results, filtering out exceptions and None values - for row_result in row_results: - if not isinstance(row_result, Exception) and row_result is not None: - results.append(row_result) - - # Save to databases - if results: - print(f"Successfully processed {len(results)} investors") - await self._save_to_sql(results) - await self._save_to_vector_db(results) - - return results diff --git a/app/services/querying.py b/app/services/querying.py index e76f94f..11b38f4 100644 --- a/app/services/querying.py +++ b/app/services/querying.py @@ -1,88 +1,47 @@ -from typing import List, Optional +import os +from typing import List -import chromadb +from db.db import DATABASE_URL, get_db from db.models import InvestorTable from langchain import hub from langchain_community.agent_toolkits import SQLDatabaseToolkit from langchain_community.utilities import SQLDatabase from langchain_openai import ChatOpenAI from langgraph.prebuilt import create_react_agent -from py_schemas import InvestorData, InvestorList -from settings import settings +from schemas.py_schemas import InvestorData, InvestorList from sqlalchemy.orm import selectinload # Connect to SQLite - prompt_template = hub.pull("langchain-ai/sql-agent-system-prompt") -db = SQLDatabase.from_uri("sqlite:///investors.db") -system_message = ( - prompt_template.format(dialect="SQLite", top_k=5) - + "\n Get answers from the Sql database and the vector database" -) +db = SQLDatabase.from_uri(DATABASE_URL) class QueryProcessor: - def __init__( - self, - sql_session: Optional[object] = None, - vector_db_client: Optional[object] = None, - ): - self.sql_session = sql_session + def __init__(self): self.llm = ChatOpenAI( - api_key=settings.OPENROUTER_API_KEY, + api_key=os.getenv("OPENROUTER_API_KEY"), base_url="https://openrouter.ai/api/v1", - model="google/gemini-2.5-flash-lite", + model="openai/gpt-5-nano", temperature=0.3, ) self.toolkit = SQLDatabaseToolkit(db=db, llm=self.llm) + # Update system message to specifically request only investor IDs + system_message_updated = ( + prompt_template.format(dialect="SQLite", top_k=5) + + "\n\nIMPORTANT: You must ONLY return the investor IDs (id field) that match the user's criteria. " + + "Do NOT return any other information, explanations, or data. " + + "Your response should be ONLY a comma-separated list of numbers representing the investor IDs. " + + "Example format: 1, 5, 12, 23" + ) self.agent = create_react_agent( model=self.llm, - tools=self.toolkit.get_tools() + [self.query_vector_database], - prompt=system_message, + tools=self.toolkit.get_tools(), + prompt=system_message_updated, ) - self.vector_db_client = vector_db_client - - self.vector_db_client = chromadb.PersistentClient(path="./chroma_db") - self.collection = self.vector_db_client.get_or_create_collection( - name="investor_descriptions", - metadata={ - "description": "Investor descriptions and investment thesis focus" - }, - ) - - def query_sql_database(self, query: str) -> Optional[InvestorList]: - """Query the SQL database for investor information.""" - if not self.sql_session: - return None - - # Implement SQL querying logic here - result = self.sql_session.execute(query) - investors = result.scalars().all() - return InvestorList(investors=investors) - - def query_vector_database(self, query: str) -> Optional[InvestorList]: - """Query the vector database for investor information.""" - if not self.vector_db_client: - return None - print("VECTOR STORE WAS CALLED") - - # Query the collection directly, not passing collection as parameter - results = self.collection.query( - query_texts=[query], # ChromaDB expects a list of query texts - n_results=3, # Specify how many results you want - ) - print(results) - - # ChromaDB returns results in a different structure - # results will have 'documents', 'metadatas', 'ids', 'distances' - return results def process_query(self, question: str) -> InvestorList: - """Process a query using the LLM and return structured investor data.""" - # Extract filters from the query first - filters = self._extract_filters_from_query(question) - - # Get AI response for additional context + """Process a query using the LLM and return investor data.""" + # Let the LLM handle all database interactions and filtering to get IDs response = self.agent.invoke( {"messages": [("user", question)]}, ) @@ -92,189 +51,68 @@ class QueryProcessor: response["messages"][-1].content if response.get("messages") else "" ) - # Try to extract investor IDs or names from the AI response - investor_ids = self._extract_investor_info_from_response(ai_response) + # Extract investor IDs from the AI response + investor_ids = self._extract_investor_ids_from_response(ai_response) - # Fetch filtered investor data with relationships from database - return self._fetch_investors_with_relationships(investor_ids, filters) + # Fetch full investor data using the IDs + return self._fetch_investors_by_ids(investor_ids) - def _extract_investor_info_from_response(self, ai_response: str) -> List[int]: - """Extract investor IDs from AI response. This is a simple implementation.""" - # This is a basic implementation - you might want to make it more sophisticated - # based on how your AI formats responses - investor_ids = [] - - # If the AI can't provide structured data, fall back to getting all investors - # that match basic criteria - try: - # Try to extract numbers that might be IDs - import re - - ids = re.findall(r"\bid:\s*(\d+)", ai_response.lower()) - investor_ids = [int(id_str) for id_str in ids] - except Exception: - pass - - return investor_ids if investor_ids else [] - - def _extract_filters_from_query(self, question: str) -> dict: - """Extract filter criteria from natural language query.""" - question_lower = question.lower() - filters = {} - - # Extract stage filters - if any( - stage in question_lower - for stage in [ - "seed", - "series a", - "series b", - "series c", - "growth", - "late stage", - ] - ): - if "seed" in question_lower: - filters["stage"] = "SEED" - elif "series a" in question_lower: - filters["stage"] = "SERIES_A" - elif "series b" in question_lower: - filters["stage"] = "SERIES_B" - elif "series c" in question_lower: - filters["stage"] = "SERIES_C" - elif "growth" in question_lower: - filters["stage"] = "GROWTH" - elif "late stage" in question_lower: - filters["stage"] = "LATE_STAGE" - - # Extract geographic filters - if any( - geo in question_lower - for geo in [ - "us", - "usa", - "united states", - "europe", - "asia", - "silicon valley", - "bay area", - ] - ): - if ( - "us" in question_lower - or "usa" in question_lower - or "united states" in question_lower - ): - filters["geography"] = "US" - elif "europe" in question_lower: - filters["geography"] = "Europe" - elif "asia" in question_lower: - filters["geography"] = "Asia" - elif "silicon valley" in question_lower or "bay area" in question_lower: - filters["geography"] = "Silicon Valley" - - # Extract sector filters - sectors = [ - "fintech", - "healthcare", - "saas", - "ai", - "biotech", - "consumer", - "enterprise", - "crypto", - "blockchain", - ] - for sector in sectors: - if sector in question_lower: - filters["sector"] = sector - break - - # Extract check size filters (simple patterns) + def _extract_investor_ids_from_response(self, ai_response: str) -> List[int]: + """Extract investor IDs from AI response.""" import re - amounts = re.findall( - r"\$?(\d+(?:,\d{3})*(?:\.\d+)?)\s*(?:million|m|k|thousand)", question_lower - ) - if amounts: - amount = amounts[0].replace(",", "") - if "million" in question_lower or "m" in question_lower: - filters["min_check_size"] = int(float(amount) * 1000000) - elif "thousand" in question_lower or "k" in question_lower: - filters["min_check_size"] = int(float(amount) * 1000) + investor_ids = [] + try: + # Try multiple patterns to extract IDs from the response + # Pattern 1: Simple numbers (assuming they are IDs) + numbers = re.findall(r"\b\d+\b", ai_response) + investor_ids = [int(num) for num in numbers] - return filters + # Pattern 2: If response contains explicit ID references + id_matches = re.findall(r"\bid[:\s]*(\d+)", ai_response.lower()) + if id_matches: + investor_ids = [int(id_str) for id_str in id_matches] - def _fetch_investors_with_relationships( - self, investor_ids: List[int] = None, filters: dict = None - ) -> InvestorList: - """Fetch investors with all their relationships from the database.""" - if not self.sql_session: + except Exception as e: + print(f"Error extracting IDs from response: {e}") + return [] + + return investor_ids + + def _fetch_investors_by_ids(self, investor_ids: List[int]) -> InvestorList: + """Fetch investors with all their relationships from the database using IDs.""" + if not investor_ids: return InvestorList(investors=[]) - # Import here to avoid circular imports - from db.models import SectorTable + # Get database session + db_session = next(get_db()) - # Build query with all relationships loaded - query = self.sql_session.query(InvestorTable).options( - selectinload(InvestorTable.portfolio_companies), - selectinload(InvestorTable.team_members), - selectinload(InvestorTable.sectors), - ) - - # Apply filters if provided - if filters: - if "stage" in filters: - from db.models import InvestmentStage - - stage_enum = getattr(InvestmentStage, filters["stage"]) - query = query.filter(InvestorTable.stage_focus == stage_enum) - - if "geography" in filters: - query = query.filter( - InvestorTable.geographic_focus.ilike(f"%{filters['geography']}%") + try: + # Build query with all relationships loaded + query = ( + db_session.query(InvestorTable) + .options( + selectinload(InvestorTable.portfolio_companies), + selectinload(InvestorTable.team_members), + selectinload(InvestorTable.sectors), ) - - if "min_check_size" in filters: - query = query.filter( - InvestorTable.check_size_lower >= filters["min_check_size"] - ) - - if "max_check_size" in filters: - query = query.filter( - InvestorTable.check_size_upper <= filters["max_check_size"] - ) - - if "min_aum" in filters: - query = query.filter(InvestorTable.aum >= filters["min_aum"]) - - if "max_aum" in filters: - query = query.filter(InvestorTable.aum <= filters["max_aum"]) - - if "sector" in filters: - query = query.join(InvestorTable.sectors).filter( - SectorTable.name.ilike(f"%{filters['sector']}%") - ) - - # Filter by IDs if provided - if investor_ids: - query = query.filter(InvestorTable.id.in_(investor_ids)) - else: - # If no specific IDs and no filters, limit to prevent overwhelming response - if not filters: - query = query.limit(10) - - investors = query.all() - - # Transform to InvestorData format - investor_data_list = [] - for investor in investors: - investor_data = InvestorData( - investor=investor, - portfolio_companies=investor.portfolio_companies, - team_members=investor.team_members, - sectors=investor.sectors, + .filter(InvestorTable.id.in_(investor_ids)) ) - investor_data_list.append(investor_data) - return InvestorList(investors=investor_data_list) + investors = query.all() + + # Transform to InvestorData format + investor_data_list = [] + for investor in investors: + investor_data = InvestorData( + investor=investor, + portfolio_companies=investor.portfolio_companies, + team_members=investor.team_members, + sectors=investor.sectors, + ) + investor_data_list.append(investor_data) + + return InvestorList(investors=investor_data_list) + + finally: + db_session.close() diff --git a/app/settings.py b/app/settings.py deleted file mode 100644 index a9376fe..0000000 --- a/app/settings.py +++ /dev/null @@ -1,11 +0,0 @@ -from pydantic_settings import BaseSettings - - -class Settings(BaseSettings): - OPENROUTER_API_KEY: str - - class Config: - env_file = ".env" - - -settings = Settings()