Skip to content

Commit 406afec

Browse files
committed
Stabilize Windows Python dependency setup
1 parent 82d7f53 commit 406afec

14 files changed

Lines changed: 515 additions & 52 deletions

File tree

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.12

AGENTS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ These instructions apply to Codex and other LLM coding agents working in this re
55
## Coding Standards
66

77
- Read `README.md`, `tasks.md`, `docs/architecture.md`, and the docs relevant to the current task before editing.
8+
- Use Python `>=3.12,<3.13` for backend work. Do not move the project to Python 3.14 until dependency wheel compatibility is verified.
89
- Keep each implementation step small and aligned with the V1/V2/V3 roadmap.
910
- Prefer typed contracts and explicit validation over ad hoc objects.
1011
- Keep SQL out of route handlers when backend code is added; use repository or service modules.
@@ -17,6 +18,11 @@ These instructions apply to Codex and other LLM coding agents working in this re
1718
Database commands available after V1.2:
1819

1920
```powershell
21+
py -3.12 -m venv .venv
22+
.\.venv\Scripts\activate
23+
python -m pip install --upgrade pip
24+
python -m pip cache purge
25+
python -m pip install -r apps/api/requirements.txt
2026
docker compose up -d postgres
2127
cd apps/api
2228
alembic upgrade head
@@ -38,6 +44,8 @@ Until those runtimes exist, validate foundation changes with:
3844
Get-ChildItem -Recurse -File
3945
```
4046

47+
Do not fix Windows dependency installation failures by adding Visual Studio Build Tools or Rust requirements. If `pydantic-core`, `maturin`, or `link.exe` errors appear, recreate the venv with Python 3.12 and reinstall from wheels.
48+
4149
## Documentation Update Rules
4250

4351
- Update `tasks.md` whenever a roadmap item changes status.

README.md

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
College Exploration Platform is a full-stack decision-support product for helping prospective and admitted students discover, compare, rank, and justify college choices with transparent data and deterministic scoring.
44

5-
Status: V1.3 FastAPI foundation complete. Search routes, frontend pages, ranking logic, Redis, pgvector, and deployment are intentionally not implemented yet.
5+
Status: V1.4 structured search API complete. Frontend pages, ranking logic, Redis, pgvector, and deployment are intentionally not implemented yet.
66

77
## Project Thesis
88

@@ -37,13 +37,44 @@ Copy-Item .env.example .env
3737
docker compose up -d postgres
3838
```
3939

40-
Install backend tooling:
40+
### Python Setup
41+
42+
Use Python `>=3.12,<3.13`. Python 3.12 is the supported local development version for this project. Do not use Python 3.14 yet; several native-extension dependencies may not have Windows wheels for it.
43+
44+
Verify your Python launcher can find Python 3.12:
4145

4246
```powershell
43-
cd apps/api
44-
python -m pip install -r requirements.txt
47+
py -3.12 --version
48+
```
49+
50+
### Virtual Environment Setup
51+
52+
Create and activate a local Python virtual environment from the project root:
53+
54+
```powershell
55+
py -3.12 -m venv .venv
56+
.\.venv\Scripts\activate
4557
```
4658

59+
Confirm the venv is active and using Python 3.12:
60+
61+
```powershell
62+
python --version
63+
python -c "import sys; print(sys.prefix)"
64+
```
65+
66+
`sys.prefix` should point at this repository's `.venv` directory.
67+
68+
### Install Dependencies
69+
70+
```powershell
71+
python -m pip install --upgrade pip
72+
python -m pip cache purge
73+
python -m pip install -r apps/api/requirements.txt
74+
```
75+
76+
Successful installs should download wheels and should not show build steps for `pydantic-core`, `psycopg`, `maturin`, Rust, or MSVC.
77+
4778
Run migrations:
4879

4980
```powershell
@@ -77,8 +108,33 @@ Useful local URLs:
77108

78109
- API health: `http://127.0.0.1:8000/health`
79110
- DB readiness: `http://127.0.0.1:8000/ready`
111+
- Structured search: `http://127.0.0.1:8000/schools/search`
80112
- OpenAPI docs: `http://127.0.0.1:8000/docs`
81113

114+
Example search request:
115+
116+
```powershell
117+
curl "http://127.0.0.1:8000/schools/search?state=CA&min_net_price=15000&max_net_price=40000&sort=net_price&page=1&page_size=10"
118+
```
119+
120+
### Windows Install Troubleshooting
121+
122+
If installation fails with `Failed building wheel for pydantic-core`, `maturin failed`, or `link.exe not found`, pip is trying to compile native code locally. That usually means the venv is using an unsupported or too-new Python version, or pip has cached an incompatible artifact.
123+
124+
Fix:
125+
126+
```powershell
127+
deactivate
128+
Remove-Item -Recurse -Force .venv
129+
py -3.12 -m venv .venv
130+
.\.venv\Scripts\activate
131+
python -m pip install --upgrade pip
132+
python -m pip cache purge
133+
python -m pip install -r apps/api/requirements.txt
134+
```
135+
136+
Do not install Visual Studio Build Tools or Rust for this project just to satisfy dependency installation. The supported path is Python 3.12 plus prebuilt wheels.
137+
82138
## Roadmap Summary
83139

84140
- V1: Production-quality MVP with database schema, FastAPI foundation, structured search, school profiles, frontend foundation, onboarding, deterministic ranking, saved schools, comparison, Redis caching, and deployment polish.
@@ -92,6 +148,9 @@ See [tasks.md](tasks.md) for the working checklist.
92148
Current backend validation commands are:
93149

94150
```powershell
151+
py -3.12 --version
152+
.\.venv\Scripts\activate
153+
python --version
95154
docker compose up -d postgres
96155
cd apps/api
97156
alembic upgrade head
@@ -107,7 +166,7 @@ Expected future commands:
107166

108167
## Limitations
109168

110-
- Only `/health` and `/ready` API endpoints exist. Search, profile, saved-school, comparison, and ranking endpoints are not implemented yet.
169+
- `/health`, `/ready`, and `/schools/search` exist. Profile, saved-school, comparison, and ranking endpoints are not implemented yet.
111170
- No UI pages, ranking engine, Redis cache, pgvector integration, or deployment exists yet.
112171
- No performance metrics are available.
113172
- Seed data is synthetic and intended for deterministic local development, not factual school reporting.

apps/api/api/routes/schools.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from typing import Annotated
2+
3+
from fastapi import APIRouter, Depends
4+
from sqlalchemy.orm import Session
5+
6+
from api.deps import get_db
7+
from schemas.schools import SearchRequest, SearchResponse
8+
from services.schools import SchoolService
9+
10+
router = APIRouter(prefix="/schools", tags=["schools"])
11+
12+
13+
def get_school_service(db: Session = Depends(get_db)) -> SchoolService:
14+
return SchoolService(db)
15+
16+
17+
@router.get(
18+
"/search",
19+
response_model=SearchResponse,
20+
summary="Structured school search",
21+
description="Returns paginated school search cards using structured filters and deterministic sorting.",
22+
)
23+
def search_schools(
24+
filters: Annotated[SearchRequest, Depends()],
25+
service: SchoolService = Depends(get_school_service),
26+
) -> SearchResponse:
27+
return service.search_schools(filters)

apps/api/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from starlette.exceptions import HTTPException as StarletteHTTPException
44

55
from api.routes.health import router as health_router
6+
from api.routes.schools import router as schools_router
67
from core.config import get_settings
78
from core.errors import (
89
http_exception_handler,
@@ -22,6 +23,7 @@ def create_app() -> FastAPI:
2223
description="Backend API foundation for the College Exploration Platform.",
2324
)
2425
app.include_router(health_router)
26+
app.include_router(schools_router)
2527
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
2628
app.add_exception_handler(RequestValidationError, validation_exception_handler)
2729
app.add_exception_handler(Exception, unhandled_exception_handler)

apps/api/repositories/schools.py

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1+
from time import perf_counter
2+
3+
from sqlalchemy import Select, func, select
14
from sqlalchemy.orm import Session
25

3-
from models.school import School
6+
from core.logging import get_logger
7+
from models.school import School, SchoolAcademics, SchoolCosts
48
from repositories.base import BaseRepository
9+
from schemas.schools import SchoolSearchResult, SearchRequest
10+
11+
logger = get_logger(__name__)
512

613

714
class SchoolRepository(BaseRepository[School]):
@@ -11,3 +18,102 @@ def __init__(self, db: Session) -> None:
1118
def get_school_by_id(self, school_id: int) -> School | None:
1219
"""Example repository method for future routes and services."""
1320
return self.db.get(School, school_id)
21+
22+
def search_schools(self, filters: SearchRequest) -> tuple[list[SchoolSearchResult], int]:
23+
base_query = (
24+
select(
25+
School.id.label("school_id"),
26+
School.name,
27+
School.city,
28+
School.state,
29+
School.type,
30+
School.setting,
31+
School.undergraduate_enrollment.label("enrollment"),
32+
School.acceptance_rate,
33+
SchoolCosts.net_price,
34+
SchoolAcademics.graduation_rate,
35+
)
36+
.join(SchoolCosts, SchoolCosts.school_id == School.id, isouter=True)
37+
.join(SchoolAcademics, SchoolAcademics.school_id == School.id, isouter=True)
38+
)
39+
filtered_query = self._apply_filters(base_query, filters)
40+
41+
total_results = self.db.scalar(
42+
select(func.count()).select_from(filtered_query.order_by(None).subquery())
43+
)
44+
total = int(total_results or 0)
45+
46+
sorted_query = self._apply_sort(filtered_query, filters)
47+
paged_query = sorted_query.offset((filters.page - 1) * filters.page_size).limit(filters.page_size)
48+
49+
start = perf_counter()
50+
rows = self.db.execute(paged_query).mappings().all()
51+
duration_ms = round((perf_counter() - start) * 1000, 2)
52+
53+
results = [
54+
SchoolSearchResult(
55+
school_id=row["school_id"],
56+
name=row["name"],
57+
city=row["city"],
58+
state=row["state"],
59+
type=row["type"],
60+
setting=row["setting"],
61+
enrollment=row["enrollment"],
62+
acceptance_rate=float(row["acceptance_rate"]) if row["acceptance_rate"] is not None else None,
63+
net_price=row["net_price"],
64+
graduation_rate=float(row["graduation_rate"]) if row["graduation_rate"] is not None else None,
65+
)
66+
for row in rows
67+
]
68+
logger.info(
69+
"school_search_query",
70+
extra={
71+
"duration_ms": duration_ms,
72+
"row_count": len(results),
73+
"total_results": total,
74+
"page": filters.page,
75+
"page_size": filters.page_size,
76+
},
77+
)
78+
return results, total
79+
80+
def _apply_filters(self, query: Select[tuple], filters: SearchRequest) -> Select[tuple]:
81+
if filters.query:
82+
query = query.where(School.name.ilike(f"%{filters.query}%"))
83+
if filters.state:
84+
query = query.where(School.state == filters.state.upper())
85+
if filters.region:
86+
query = query.where(School.region == filters.region)
87+
if filters.type:
88+
query = query.where(School.type == filters.type)
89+
if filters.setting:
90+
query = query.where(School.setting == filters.setting)
91+
if filters.min_enrollment is not None:
92+
query = query.where(School.undergraduate_enrollment >= filters.min_enrollment)
93+
if filters.max_enrollment is not None:
94+
query = query.where(School.undergraduate_enrollment <= filters.max_enrollment)
95+
if filters.min_net_price is not None:
96+
query = query.where(SchoolCosts.net_price >= filters.min_net_price)
97+
if filters.max_net_price is not None:
98+
query = query.where(SchoolCosts.net_price <= filters.max_net_price)
99+
if filters.min_acceptance_rate is not None:
100+
query = query.where(School.acceptance_rate >= filters.min_acceptance_rate)
101+
if filters.max_acceptance_rate is not None:
102+
query = query.where(School.acceptance_rate <= filters.max_acceptance_rate)
103+
if filters.min_graduation_rate is not None:
104+
query = query.where(SchoolAcademics.graduation_rate >= filters.min_graduation_rate)
105+
if filters.max_graduation_rate is not None:
106+
query = query.where(SchoolAcademics.graduation_rate <= filters.max_graduation_rate)
107+
return query
108+
109+
def _apply_sort(self, query: Select[tuple], filters: SearchRequest) -> Select[tuple]:
110+
sort_columns = {
111+
"name": School.name,
112+
"net_price": SchoolCosts.net_price,
113+
"graduation_rate": SchoolAcademics.graduation_rate,
114+
"acceptance_rate": School.acceptance_rate,
115+
"enrollment": School.undergraduate_enrollment,
116+
}
117+
sort_column = sort_columns[filters.sort]
118+
sort_expression = sort_column.desc().nulls_last() if filters.direction == "desc" else sort_column.asc().nulls_last()
119+
return query.order_by(sort_expression, School.id.asc())

apps/api/requirements.txt

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
alembic==1.13.3
2-
fastapi==0.115.4
3-
httpx==0.27.2
4-
psycopg[binary]==3.2.3
5-
pydantic-settings==2.6.1
6-
pytest==8.3.3
7-
SQLAlchemy==2.0.36
8-
uvicorn[standard]==0.32.0
1+
alembic>=1.13.3,<1.15
2+
fastapi>=0.115.4,<0.116
3+
httpx>=0.27.2,<0.28
4+
psycopg[binary]>=3.2.10,<3.4
5+
pydantic>=2.10,<2.12
6+
pydantic-settings>=2.6.1,<2.8
7+
pytest>=8.3.3,<9
8+
python-dotenv>=1.0.1,<2
9+
SQLAlchemy>=2.0.36,<2.1
10+
uvicorn[standard]>=0.32,<0.35

apps/api/schemas/schools.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
from typing import Literal
22

3-
from pydantic import BaseModel, ConfigDict, Field
3+
from pydantic import BaseModel, ConfigDict, Field, model_validator
44

55

66
SchoolSort = Literal[
77
"name",
88
"acceptance_rate",
99
"graduation_rate",
1010
"net_price",
11-
"undergraduate_enrollment",
11+
"enrollment",
1212
]
1313
SortDirection = Literal["asc", "desc"]
1414

@@ -37,6 +37,23 @@ class SchoolProfile(BaseModel):
3737
campus_tags: list[str] = Field(default_factory=list)
3838

3939

40+
class SchoolSearchResult(BaseModel):
41+
school_id: int
42+
name: str
43+
city: str
44+
state: str
45+
type: str
46+
setting: str
47+
enrollment: int | None = None
48+
acceptance_rate: float | None = None
49+
net_price: int | None = None
50+
graduation_rate: float | None = None
51+
fit_score: float | None = None
52+
confidence_score: float | None = None
53+
top_reasons: list[str] = Field(default_factory=list)
54+
top_tradeoffs: list[str] = Field(default_factory=list)
55+
56+
4057
class SearchRequest(BaseModel):
4158
query: str | None = Field(default=None, max_length=120)
4259
state: str | None = Field(default=None, min_length=2, max_length=2)
@@ -45,17 +62,33 @@ class SearchRequest(BaseModel):
4562
setting: str | None = Field(default=None, max_length=32)
4663
min_enrollment: int | None = Field(default=None, ge=0)
4764
max_enrollment: int | None = Field(default=None, ge=0)
65+
min_net_price: int | None = Field(default=None, ge=0)
4866
max_net_price: int | None = Field(default=None, ge=0)
67+
min_acceptance_rate: float | None = Field(default=None, ge=0, le=1)
4968
max_acceptance_rate: float | None = Field(default=None, ge=0, le=1)
5069
min_graduation_rate: float | None = Field(default=None, ge=0, le=1)
70+
max_graduation_rate: float | None = Field(default=None, ge=0, le=1)
5171
sort: SchoolSort = "name"
5272
direction: SortDirection = "asc"
5373
page: int = Field(default=1, ge=1)
54-
page_size: int = Field(default=20, ge=1, le=100)
74+
page_size: int = Field(default=20, ge=1, le=50)
75+
76+
@model_validator(mode="after")
77+
def validate_ranges(self) -> "SearchRequest":
78+
ranges = [
79+
("min_enrollment", self.min_enrollment, "max_enrollment", self.max_enrollment),
80+
("min_net_price", self.min_net_price, "max_net_price", self.max_net_price),
81+
("min_acceptance_rate", self.min_acceptance_rate, "max_acceptance_rate", self.max_acceptance_rate),
82+
("min_graduation_rate", self.min_graduation_rate, "max_graduation_rate", self.max_graduation_rate),
83+
]
84+
for min_name, min_value, max_name, max_value in ranges:
85+
if min_value is not None and max_value is not None and min_value > max_value:
86+
raise ValueError(f"{min_name} cannot be greater than {max_name}")
87+
return self
5588

5689

5790
class SearchResponse(BaseModel):
58-
results: list[SchoolSummary] = Field(default_factory=list)
91+
results: list[SchoolSearchResult] = Field(default_factory=list)
5992
page: int = 1
6093
page_size: int = 20
6194
total_results: int = 0

0 commit comments

Comments
 (0)