Skip to content

Commit 9516fe4

Browse files
committed
Part 1 of deploying ML model. Created a minimal test endpoint and page to validate end-to-end.
1 parent ac80e4e commit 9516fe4

File tree

8 files changed

+672
-20
lines changed

8 files changed

+672
-20
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { NextResponse } from "next/server";
2+
3+
// Avoid direct Node `process` typing to satisfy edge runtimes and linting
4+
function getPythonBaseUrl(): string {
5+
const env = (globalThis as any)?.process?.env as
6+
| Record<string, string | undefined>
7+
| undefined;
8+
return env?.PY_UTILIZATION_BASE_URL || "http://127.0.0.1:8001";
9+
}
10+
11+
export async function GET(): Promise<Response> {
12+
const baseUrl = getPythonBaseUrl();
13+
14+
const controller = new AbortController();
15+
const timeout = setTimeout(() => controller.abort(), 8000);
16+
try {
17+
const res = await fetch(`${baseUrl}/health`, {
18+
method: "GET",
19+
signal: controller.signal,
20+
headers: {
21+
accept: "application/json",
22+
},
23+
// ensure server-side only
24+
cache: "no-store",
25+
});
26+
clearTimeout(timeout);
27+
28+
if (!res.ok) {
29+
const text = await res.text().catch(() => "");
30+
return NextResponse.json(
31+
{
32+
status: "error",
33+
code: res.status,
34+
detail: text || "Python service health check failed",
35+
},
36+
{ status: 502 }
37+
);
38+
}
39+
40+
const data = await res.json().catch(() => ({}));
41+
return NextResponse.json({ status: "ok", upstream: data }, { status: 200 });
42+
} catch (error: unknown) {
43+
clearTimeout(timeout);
44+
const message = error instanceof Error ? error.message : "Unknown error";
45+
const status = message.includes("The user aborted a request") ? 504 : 500;
46+
return NextResponse.json(
47+
{ status: "error", code: status, detail: message },
48+
{ status }
49+
);
50+
}
51+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* Utilization Prediction API Route
3+
*
4+
* Bridges the Next.js app to the Python FastAPI utilization service. Accepts a
5+
* JSON body of optional numeric features and forwards them to the upstream
6+
* `/predict` endpoint. Validates the upstream response to ensure it includes
7+
* all expected utilization count fields before returning to the client.
8+
*
9+
* Key behavior:
10+
* - Reads `PY_UTILIZATION_BASE_URL` env var (defaults to http://127.0.0.1:8001)
11+
* - 15s timeout with abort controller
12+
* - Returns 502 if upstream fails or response shape is invalid
13+
* - Returns 504 on client-aborted timeout, otherwise 500 on unknown errors
14+
*/
15+
import { NextResponse } from "next/server";
16+
17+
// Minimal schema validation without bringing in zod here
18+
type PredictRequest = Record<string, unknown>;
19+
20+
interface PredictResponse {
21+
pcp_visits: number;
22+
outpatient_visits: number;
23+
er_visits: number;
24+
inpatient_admits: number;
25+
home_health_visits: number;
26+
rx_fills: number;
27+
dental_visits: number;
28+
equipment_purchases: number;
29+
}
30+
31+
// Avoid direct Node `process` typing to satisfy edge runtimes and linting
32+
function getPythonBaseUrl(): string {
33+
const env = (globalThis as any)?.process?.env as
34+
| Record<string, string | undefined>
35+
| undefined;
36+
return env?.PY_UTILIZATION_BASE_URL || "http://127.0.0.1:8001";
37+
}
38+
39+
function isValidResponse(json: any): json is PredictResponse {
40+
if (!json || typeof json !== "object") return false;
41+
const keys = [
42+
"pcp_visits",
43+
"outpatient_visits",
44+
"er_visits",
45+
"inpatient_admits",
46+
"home_health_visits",
47+
"rx_fills",
48+
"dental_visits",
49+
"equipment_purchases",
50+
] as const;
51+
return keys.every(
52+
(k) => typeof json[k] === "number" && Number.isFinite(json[k])
53+
);
54+
}
55+
56+
export async function POST(req: Request): Promise<Response> {
57+
const baseUrl = getPythonBaseUrl();
58+
let body: PredictRequest;
59+
try {
60+
body = await req.json();
61+
} catch {
62+
return NextResponse.json(
63+
{ status: "error", detail: "Invalid JSON body" },
64+
{ status: 400 }
65+
);
66+
}
67+
68+
const controller = new AbortController();
69+
const timeout = setTimeout(() => controller.abort(), 15000);
70+
try {
71+
const res = await fetch(`${baseUrl}/predict`, {
72+
method: "POST",
73+
signal: controller.signal,
74+
headers: {
75+
"content-type": "application/json",
76+
accept: "application/json",
77+
},
78+
body: JSON.stringify(body),
79+
cache: "no-store",
80+
});
81+
clearTimeout(timeout);
82+
83+
const text = await res.text().catch(() => "");
84+
let json: unknown;
85+
try {
86+
json = text ? JSON.parse(text) : null;
87+
} catch {
88+
json = null;
89+
}
90+
91+
if (!res.ok || !isValidResponse(json)) {
92+
return NextResponse.json(
93+
{
94+
status: "error",
95+
code: res.status,
96+
detail: "Upstream prediction failed or returned invalid response",
97+
upstream: text?.slice(0, 500),
98+
},
99+
{ status: 502 }
100+
);
101+
}
102+
103+
return NextResponse.json(json as PredictResponse, { status: 200 });
104+
} catch (error: unknown) {
105+
clearTimeout(timeout);
106+
const message = error instanceof Error ? error.message : "Unknown error";
107+
const status = message.includes("The user aborted a request") ? 504 : 500;
108+
return NextResponse.json(
109+
{ status: "error", code: status, detail: message },
110+
{ status }
111+
);
112+
}
113+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"use client";
2+
3+
/*
4+
Utilization Model Test Page
5+
- Purpose: Simple client UI to validate the end-to-end utilization prediction flow.
6+
- Actions:
7+
- "Health Check" calls `/api/utilization-model/health` to ensure the Python FastAPI
8+
service and models are available.
9+
- "Predict" posts minimal features (e.g., age, BMI) to `/api/utilization-model/predict`
10+
and displays the returned annual utilization counts.
11+
- Note: The "use client" directive must remain the first statement in this file.
12+
*/
13+
14+
import { Button } from "@/components/ui/button";
15+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
16+
import { Input } from "@/components/ui/input";
17+
import { Label } from "@/components/ui/label";
18+
import { useState } from "react";
19+
20+
type PredictResponse = {
21+
pcp_visits: number;
22+
outpatient_visits: number;
23+
er_visits: number;
24+
inpatient_admits: number;
25+
home_health_visits: number;
26+
rx_fills: number;
27+
dental_visits: number;
28+
equipment_purchases: number;
29+
};
30+
31+
export default function UtilizationModelTestPage() {
32+
const [age, setAge] = useState<string>("");
33+
const [bmi, setBmi] = useState<string>("");
34+
const [loading, setLoading] = useState(false);
35+
const [healthStatus, setHealthStatus] = useState<string>("");
36+
const [result, setResult] = useState<PredictResponse | null>(null);
37+
const [error, setError] = useState<string>("");
38+
39+
async function checkHealth() {
40+
setHealthStatus("checking...");
41+
setError("");
42+
try {
43+
const res = await fetch("/api/utilization-model/health");
44+
const json = await res.json();
45+
if (!res.ok) throw new Error(json?.detail || "Service error");
46+
setHealthStatus("Python service is healthy");
47+
} catch (e: any) {
48+
setHealthStatus("");
49+
setError(e?.message || "Health check failed");
50+
}
51+
}
52+
53+
async function predict() {
54+
setLoading(true);
55+
setResult(null);
56+
setError("");
57+
try {
58+
const features: Record<string, any> = {};
59+
if (age.trim()) features.age_years_2022 = Number(age);
60+
if (bmi.trim()) features.bmi = Number(bmi);
61+
62+
const res = await fetch("/api/utilization-model/predict", {
63+
method: "POST",
64+
headers: { "content-type": "application/json" },
65+
body: JSON.stringify(features),
66+
});
67+
const json = await res.json();
68+
if (!res.ok) throw new Error(json?.detail || "Prediction failed");
69+
setResult(json as PredictResponse);
70+
} catch (e: any) {
71+
setError(e?.message || "Prediction failed");
72+
} finally {
73+
setLoading(false);
74+
}
75+
}
76+
77+
return (
78+
<div className="max-w-2xl mx-auto p-6 space-y-6">
79+
<div className="flex items-center justify-between">
80+
<h1 className="text-2xl font-bold">Utilization Model Test</h1>
81+
<Button variant="outline" onClick={checkHealth}>
82+
Health Check
83+
</Button>
84+
</div>
85+
{healthStatus && (
86+
<div className="text-sm text-green-700">{healthStatus}</div>
87+
)}
88+
{error && <div className="text-sm text-red-600">{error}</div>}
89+
90+
<Card>
91+
<CardHeader>
92+
<CardTitle>Enter Minimal Features</CardTitle>
93+
</CardHeader>
94+
<CardContent className="space-y-4">
95+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
96+
<div>
97+
<Label className="text-sm">Age (years)</Label>
98+
<Input
99+
type="number"
100+
value={age}
101+
onChange={(e) => setAge(e.target.value)}
102+
placeholder="e.g., 45"
103+
/>
104+
</div>
105+
<div>
106+
<Label className="text-sm">BMI</Label>
107+
<Input
108+
type="number"
109+
value={bmi}
110+
onChange={(e) => setBmi(e.target.value)}
111+
placeholder="e.g., 27.5"
112+
/>
113+
</div>
114+
</div>
115+
<Button onClick={predict} disabled={loading} className="w-full">
116+
{loading ? "Predicting..." : "Predict"}
117+
</Button>
118+
</CardContent>
119+
</Card>
120+
121+
{result && (
122+
<Card>
123+
<CardHeader>
124+
<CardTitle>Predicted Annual Counts</CardTitle>
125+
</CardHeader>
126+
<CardContent>
127+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
128+
<div className="flex justify-between">
129+
<span>Primary care visits</span>
130+
<span>{result.pcp_visits}</span>
131+
</div>
132+
<div className="flex justify-between">
133+
<span>Outpatient visits</span>
134+
<span>{result.outpatient_visits}</span>
135+
</div>
136+
<div className="flex justify-between">
137+
<span>ER visits</span>
138+
<span>{result.er_visits}</span>
139+
</div>
140+
<div className="flex justify-between">
141+
<span>Inpatient admits</span>
142+
<span>{result.inpatient_admits}</span>
143+
</div>
144+
<div className="flex justify-between">
145+
<span>Home health visits</span>
146+
<span>{result.home_health_visits}</span>
147+
</div>
148+
<div className="flex justify-between">
149+
<span>Prescription fills</span>
150+
<span>{result.rx_fills}</span>
151+
</div>
152+
<div className="flex justify-between">
153+
<span>Dental visits</span>
154+
<span>{result.dental_visits}</span>
155+
</div>
156+
<div className="flex justify-between">
157+
<span>Durable equipment purchases</span>
158+
<span>{result.equipment_purchases}</span>
159+
</div>
160+
</div>
161+
</CardContent>
162+
</Card>
163+
)}
164+
</div>
165+
);
166+
}

0 commit comments

Comments
 (0)