Skip to content

Commit 0351fef

Browse files
committed
Implement deterministic ranking engine
1 parent 361b75b commit 0351fef

13 files changed

Lines changed: 1186 additions & 36 deletions

File tree

README.md

Lines changed: 8 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.8 onboarding and local preference profile complete. Ranking logic, Redis, pgvector, and deployment are intentionally not implemented yet.
5+
Status: V1.9 deterministic ranking engine complete. Redis, pgvector, saved schools, comparisons, and deployment are intentionally not implemented yet.
66

77
## Project Thesis
88

@@ -109,6 +109,7 @@ Useful local URLs:
109109
- API health: `http://127.0.0.1:8000/health`
110110
- DB readiness: `http://127.0.0.1:8000/ready`
111111
- Structured search: `http://127.0.0.1:8000/schools/search`
112+
- Deterministic rankings: `http://127.0.0.1:8000/rankings`
112113
- School profile: `http://127.0.0.1:8000/schools/1`
113114
- OpenAPI docs: `http://127.0.0.1:8000/docs`
114115

@@ -124,10 +125,12 @@ Example profile request:
124125
curl "http://127.0.0.1:8000/schools/1"
125126
```
126127

127-
`GET /schools/{id}` composes a full profile from the core `schools` row plus academic, cost, outcome, and campus-life tables. The API keeps ranking placeholders such as `fit_score`, `category_scores`, reasons, tradeoffs, and `similar_schools` empty until those roadmap steps are implemented.
128+
`GET /schools/{id}` composes a full profile from the core `schools` row plus academic, cost, outcome, and campus-life tables. Profile ranking placeholders such as `fit_score`, `category_scores`, reasons, tradeoffs, and `similar_schools` remain empty until the frontend profile workflow consumes ranking output.
128129

129130
Missing data is treated as unknown. The API returns `null` for missing values, lists those fields in `data_fields_missing`, and includes a simple `data_confidence_score` based on profile completeness. It does not convert missing numbers to zero or infer school facts that are not in the database.
130131

132+
`POST /rankings` ranks search-card results against a supplied preference profile using deterministic V1.0 category scoring, normalized weights, confidence scores, hard constraints, and reason-code explanations. Ranking does not use semantic search, ML models, or LLM-generated scoring.
133+
131134
### Frontend Setup
132135

133136
The V1.6 frontend lives in `apps/web` and uses the Next.js App Router with TypeScript, Tailwind CSS, and small shadcn/ui-compatible component primitives.
@@ -217,12 +220,12 @@ Expected future commands:
217220

218221
## Limitations
219222

220-
- `/health`, `/ready`, `/schools/search`, and `/schools/{id}` exist. Preference persistence, saved-school, comparison, and ranking endpoints are not implemented yet.
223+
- `/health`, `/ready`, `/schools/search`, `/schools/{id}`, and `/rankings` exist. Preference persistence, saved-school, and comparison endpoints are not implemented yet.
221224
- The frontend has a landing page, onboarding, search UI, route shell, UI primitives, and typed API client, but no backend preference persistence, persisted saved-school flows, comparison workflow, or profile pages yet.
222225
- Onboarding stores a typed `PreferenceProfile` in browser `localStorage` and forwards supported filters such as state, setting, school type, and max net price to `/search`.
223226
- Search supports structured filters, sort controls, URL state, pagination, local save/compare state, loading/empty/error states, and API-backed result cards.
224-
- The Best fit sort is a UI placeholder that falls back to name sorting until deterministic ranking exists in V1.9.
225-
- No ranking engine, Redis cache, pgvector integration, or deployment exists yet.
227+
- The "Best fit" sort is a UI placeholder until the frontend calls `POST /rankings`.
228+
- No Redis cache, pgvector integration, or deployment exists yet.
226229
- No performance metrics are available.
227230
- Seed data is synthetic and intended for deterministic local development, not factual school reporting.
228231
- End-to-end validation will be added after the product workflows exist.

apps/api/api/routes/rankings.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from fastapi import APIRouter, Depends
2+
from sqlalchemy.orm import Session
3+
4+
from api.deps import get_db
5+
from repositories.schools import SchoolRepository
6+
from schemas.rankings import RankingRequest, RankingResponse
7+
from services.ranking_service import RankingService
8+
9+
router = APIRouter(prefix="/rankings", tags=["rankings"])
10+
11+
12+
def get_ranking_service(db: Session = Depends(get_db)) -> RankingService:
13+
return RankingService(SchoolRepository(db))
14+
15+
16+
@router.post(
17+
"",
18+
response_model=RankingResponse,
19+
summary="Rank schools deterministically",
20+
description="Returns ranked school search cards using structured preferences and deterministic reason codes.",
21+
)
22+
def rank_schools(
23+
request: RankingRequest,
24+
service: RankingService = Depends(get_ranking_service),
25+
) -> RankingResponse:
26+
return service.rank_schools(request)

apps/api/main.py

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

66
from api.routes.health import router as health_router
7+
from api.routes.rankings import router as rankings_router
78
from api.routes.schools import router as schools_router
89
from core.config import get_settings
910
from core.errors import (
@@ -26,6 +27,7 @@ def create_app() -> FastAPI:
2627
)
2728
app.include_router(health_router)
2829
app.include_router(schools_router)
30+
app.include_router(rankings_router)
2931
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
3032
app.add_exception_handler(RequestValidationError, validation_exception_handler)
3133
app.add_exception_handler(PydanticValidationError, pydantic_validation_exception_handler)

apps/api/repositories/schools.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,43 @@ def search_schools(self, filters: SearchRequest) -> tuple[list[SchoolSearchResul
109109
)
110110
return results, total
111111

112+
def get_ranking_candidate_rows(self, filters: SearchRequest) -> list[dict[str, object]]:
113+
query = (
114+
select(
115+
School.id.label("school_id"),
116+
School.name,
117+
School.city,
118+
School.state,
119+
School.region,
120+
School.type,
121+
School.setting,
122+
School.undergraduate_enrollment.label("enrollment"),
123+
School.acceptance_rate,
124+
SchoolAcademics.top_majors,
125+
SchoolAcademics.graduation_rate,
126+
SchoolAcademics.retention_rate,
127+
SchoolAcademics.student_faculty_ratio,
128+
SchoolCosts.tuition_in_state,
129+
SchoolCosts.tuition_out_state,
130+
SchoolCosts.net_price,
131+
SchoolCosts.average_aid,
132+
SchoolCosts.debt_median,
133+
SchoolOutcomes.median_earnings,
134+
SchoolOutcomes.repayment_rate,
135+
SchoolCampusLife.housing_available,
136+
SchoolCampusLife.sports_division,
137+
SchoolCampusLife.greek_life_rate,
138+
SchoolCampusLife.culture_tags,
139+
)
140+
.join(SchoolAcademics, SchoolAcademics.school_id == School.id, isouter=True)
141+
.join(SchoolCosts, SchoolCosts.school_id == School.id, isouter=True)
142+
.join(SchoolOutcomes, SchoolOutcomes.school_id == School.id, isouter=True)
143+
.join(SchoolCampusLife, SchoolCampusLife.school_id == School.id, isouter=True)
144+
)
145+
filtered_query = self._apply_filters(query, filters).order_by(School.id.asc())
146+
rows = self.db.execute(filtered_query).mappings().all()
147+
return [dict(row) for row in rows]
148+
112149
def _apply_filters(self, query: Select[tuple], filters: SearchRequest) -> Select[tuple]:
113150
if filters.query:
114151
query = query.where(School.name.ilike(f"%{filters.query}%"))

apps/api/schemas/preferences.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from pydantic import BaseModel, ConfigDict, Field
44

55
JsonScalar: TypeAlias = str | int | float | bool | None
6+
JsonValue: TypeAlias = JsonScalar | list[JsonScalar] | dict[str, JsonScalar]
67

78

89
class Preference(BaseModel):
@@ -22,4 +23,4 @@ class Preference(BaseModel):
2223
home_state: str | None = Field(default=None, min_length=2, max_length=2)
2324
max_annual_cost: int | None = Field(default=None, ge=0)
2425
weights: dict[str, float] = Field(default_factory=dict)
25-
constraints: dict[str, JsonScalar] = Field(default_factory=dict)
26+
constraints: dict[str, JsonValue] = Field(default_factory=dict)

apps/api/schemas/rankings.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from pydantic import BaseModel, ConfigDict, Field
22

3+
from schemas.preferences import Preference
4+
from schemas.schools import SchoolSearchResult, SearchRequest
5+
36

47
class RankingScore(BaseModel):
58
school_id: int
@@ -8,17 +11,50 @@ class RankingScore(BaseModel):
811
category_scores: dict[str, float] = Field(default_factory=dict)
912
reason_codes: list[str] = Field(default_factory=list)
1013
tradeoff_codes: list[str] = Field(default_factory=list)
14+
ranking_version: str
15+
16+
17+
class RankingRequest(BaseModel):
18+
preferences: Preference
19+
filters: SearchRequest = Field(default_factory=SearchRequest)
1120

1221

1322
class RankingResponse(BaseModel):
1423
model_config = ConfigDict(
1524
json_schema_extra={
1625
"example": {
17-
"ranking_version": "placeholder",
18-
"results": [],
26+
"ranking_version": "v1.0",
27+
"results": [
28+
{
29+
"school_id": 2,
30+
"name": "Bayview Technical University",
31+
"city": "New Haven",
32+
"state": "CT",
33+
"type": "Public",
34+
"setting": "Urban",
35+
"enrollment": 11800,
36+
"acceptance_rate": 0.52,
37+
"net_price": 24400,
38+
"graduation_rate": 0.78,
39+
"fit_score": 86.42,
40+
"confidence_score": 0.95,
41+
"category_scores": {"academic": 92.0, "cost": 82.5},
42+
"top_reasons": ["academic_major_match", "cost_within_budget"],
43+
"top_tradeoffs": ["admissions_more_selective_than_target"],
44+
"ranking_version": "v1.0",
45+
}
46+
],
47+
"page": 1,
48+
"page_size": 20,
49+
"total_results": 1,
50+
"has_next": False,
1951
}
2052
}
2153
)
2254

2355
ranking_version: str
24-
results: list[RankingScore] = Field(default_factory=list)
56+
results: list[SchoolSearchResult] = Field(default_factory=list)
57+
page: int = 1
58+
page_size: int = 20
59+
total_results: int = 0
60+
has_next: bool = False

apps/api/schemas/schools.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,10 @@ class SchoolSearchResult(BaseModel):
104104
graduation_rate: float | None = None
105105
fit_score: float | None = None
106106
confidence_score: float | None = None
107+
category_scores: dict[str, float] = Field(default_factory=dict)
107108
top_reasons: list[str] = Field(default_factory=list)
108109
top_tradeoffs: list[str] = Field(default_factory=list)
110+
ranking_version: str | None = None
109111

110112

111113
class SearchRequest(BaseModel):

0 commit comments

Comments
 (0)