| from __future__ import annotations |
|
|
| from typing import Any |
|
|
| from fastapi.responses import HTMLResponse |
| from fastapi import FastAPI |
| import uvicorn |
| from openenv.core.env_server import create_fastapi_app |
|
|
| from logging_utils import get_logger |
| from .environment import CareerAction, CareerEnvironment, CareerObservation, CareerState |
|
|
| openenv_app: FastAPI = create_fastapi_app( |
| CareerEnvironment, |
| action_cls=CareerAction, |
| observation_cls=CareerObservation, |
| ) |
| logger = get_logger("server") |
|
|
| app = FastAPI(title="AI Career Advisor") |
| app.mount("/openenv", openenv_app) |
|
|
| UI_HTML = """ |
| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>AI Career Advisor</title> |
| <style> |
| :root { |
| --bg: #090d16; |
| --panel: #1c212c; |
| --panel-border: #3a404f; |
| --text: #f8fafc; |
| --muted: #b4bccb; |
| --primary: #c7440d; |
| --primary-hover: #e45517; |
| --table-row: #111827; |
| --chip: #0b1220; |
| } |
| * { box-sizing: border-box; } |
| body { |
| margin: 0; |
| font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif; |
| background: linear-gradient(160deg, #070a13 0%, #0a1020 50%, #0a0f1a 100%); |
| color: var(--text); |
| min-height: 100vh; |
| } |
| .wrap { |
| width: min(1120px, 94vw); |
| margin: 24px auto 18px; |
| } |
| h1 { |
| margin: 0 0 10px 0; |
| font-size: 38px; |
| line-height: 1.1; |
| letter-spacing: 0.3px; |
| } |
| .intro { |
| margin: 0 0 16px 0; |
| color: var(--muted); |
| font-size: 15px; |
| } |
| .intro code { |
| color: #f5f5f5; |
| background: #101726; |
| border: 1px solid #2e3646; |
| padding: 2px 6px; |
| border-radius: 6px; |
| } |
| .card { |
| background: linear-gradient(90deg, rgba(39, 45, 60, 0.92), rgba(33, 38, 49, 0.92)); |
| border: 1px solid var(--panel-border); |
| border-radius: 8px; |
| padding: 12px; |
| margin-bottom: 12px; |
| } |
| .label { |
| display: block; |
| font-weight: 600; |
| margin-bottom: 10px; |
| color: #e5e7eb; |
| } |
| textarea { |
| width: 100%; |
| min-height: 66px; |
| resize: vertical; |
| border-radius: 6px; |
| border: 1px solid #3a4150; |
| background: #232833; |
| color: var(--text); |
| padding: 10px; |
| font-size: 15px; |
| outline: none; |
| } |
| textarea:focus { |
| border-color: #56617a; |
| box-shadow: 0 0 0 2px rgba(103, 126, 178, 0.2); |
| } |
| .btn { |
| width: 100%; |
| border: 0; |
| border-radius: 7px; |
| background: var(--primary); |
| color: #fff; |
| font-weight: 700; |
| font-size: 20px; |
| line-height: 1; |
| padding: 14px 12px; |
| margin: 4px 0 12px; |
| cursor: pointer; |
| transition: background 0.15s ease; |
| } |
| .btn:hover { background: var(--primary-hover); } |
| .btn:disabled { |
| background: #6b7280; |
| cursor: not-allowed; |
| } |
| .section-title { |
| margin: 16px 0 8px; |
| font-size: 22px; |
| font-weight: 700; |
| } |
| .out { |
| min-height: 126px; |
| white-space: pre-wrap; |
| line-height: 1.45; |
| font-size: 15px; |
| } |
| .table-wrap { |
| overflow-x: auto; |
| border-radius: 8px; |
| border: 1px solid #2b3342; |
| } |
| table { |
| width: 100%; |
| border-collapse: collapse; |
| table-layout: fixed; |
| font-size: 14px; |
| background: #0a111f; |
| } |
| th, td { |
| border: 1px solid #2b3342; |
| padding: 10px; |
| text-align: left; |
| vertical-align: top; |
| word-wrap: break-word; |
| } |
| th { |
| background: #0b1220; |
| font-weight: 700; |
| color: #f3f4f6; |
| } |
| tbody tr:nth-child(odd) { background: var(--table-row); } |
| .examples { |
| margin-top: 14px; |
| color: #e5e7eb; |
| font-size: 14px; |
| } |
| .chips { |
| margin-top: 10px; |
| display: flex; |
| flex-wrap: wrap; |
| gap: 10px; |
| } |
| .chip { |
| padding: 8px 12px; |
| border-radius: 10px; |
| border: 1px solid #334155; |
| background: var(--chip); |
| color: #fff; |
| cursor: pointer; |
| font-size: 14px; |
| } |
| .footer { |
| text-align: center; |
| color: #9ca3af; |
| font-size: 14px; |
| margin: 40px 0 10px; |
| } |
| @media (max-width: 900px) { |
| .wrap { width: 95vw; margin-top: 16px; } |
| h1 { font-size: 30px; } |
| .intro { font-size: 14px; } |
| .btn { font-size: 18px; padding: 12px 10px; } |
| .section-title { font-size: 20px; } |
| .out { font-size: 14px; } |
| table { font-size: 13px; } |
| } |
| </style> |
| </head> |
| <body> |
| <div class="wrap"> |
| <h1>AI Career Advisor</h1> |
| <p class="intro"> |
| Enter your current skills to get a career recommendation and simulation. |
| OpenEnv routes stay available at <code>/openenv/reset</code>, <code>/openenv/step</code>, and <code>/openenv/state</code>. |
| </p> |
| |
| <div class="card"> |
| <label class="label" for="skillsInput">Current skills</label> |
| <textarea id="skillsInput" placeholder="python, excel"></textarea> |
| </div> |
| |
| <button id="runBtn" class="btn" disabled>Run simulation</button> |
| |
| <div class="card"> |
| <div class="label">Career recommendation</div> |
| <div id="recommendation" class="out"></div> |
| </div> |
| |
| <div class="section-title">Top career matches</div> |
| <div class="table-wrap"> |
| <table> |
| <thead> |
| <tr> |
| <th>role</th> |
| <th>current fit</th> |
| <th>matched skills</th> |
| <th>missing skills</th> |
| <th>base salary</th> |
| <th>market demand</th> |
| </tr> |
| </thead> |
| <tbody id="matchesBody"></tbody> |
| </table> |
| </div> |
| |
| <div class="section-title">Simulation steps</div> |
| <div class="table-wrap"> |
| <table> |
| <thead> |
| <tr> |
| <th>step</th> |
| <th>action</th> |
| <th>career</th> |
| <th>salary</th> |
| <th>reward</th> |
| <th>feedback</th> |
| </tr> |
| </thead> |
| <tbody id="stepsBody"></tbody> |
| </table> |
| </div> |
| |
| <div class="examples"> |
| <div>Examples</div> |
| <div class="chips"> |
| <button class="chip" data-example="python">python</button> |
| <button class="chip" data-example="python, machine learning">python, machine learning</button> |
| <button class="chip" data-example="python, statistics">python, statistics</button> |
| <button class="chip" data-example="python, statistics, excel">python, statistics, excel</button> |
| <button class="chip" data-example="excel">excel</button> |
| <button class="chip" data-example="excel, communication">excel, communication</button> |
| </div> |
| </div> |
| |
| <div class="footer">Built with FastAPI HTML UI</div> |
| </div> |
| |
| <script> |
| const CAREERS = [ |
| { |
| role: "AI Engineer", |
| skills: ["python", "machine learning"], |
| baseSalary: 90000, |
| demand: 95, |
| }, |
| { |
| role: "Data Analyst", |
| skills: ["python", "statistics", "excel"], |
| baseSalary: 60000, |
| demand: 75, |
| }, |
| { |
| role: "Accountant", |
| skills: ["excel", "communication"], |
| baseSalary: 45000, |
| demand: 55, |
| }, |
| ]; |
| |
| const BONUS = [0.95, 1.077, 1.081, 1.081, 1.09, 1.089]; |
| |
| const skillsInput = document.getElementById("skillsInput"); |
| const recommendation = document.getElementById("recommendation"); |
| const matchesBody = document.getElementById("matchesBody"); |
| const stepsBody = document.getElementById("stepsBody"); |
| const runBtn = document.getElementById("runBtn"); |
| |
| function setEmptyState() { |
| recommendation.textContent = "Enter skills and click Run simulation to see recommendation."; |
| matchesBody.innerHTML = '<tr><td colspan="6">No results yet. Run simulation to populate career matches.</td></tr>'; |
| stepsBody.innerHTML = '<tr><td colspan="6">No simulation yet. Add skills and click Run simulation.</td></tr>'; |
| } |
| |
| function updateRunButtonState() { |
| runBtn.disabled = !skillsInput.value.trim(); |
| } |
| |
| function parseSkills(text) { |
| return [...new Set( |
| (text || "") |
| .split(",") |
| .map((x) => x.trim().toLowerCase()) |
| .filter(Boolean) |
| )]; |
| } |
| |
| function fmtMoney(value) { |
| return "$" + Math.round(value).toLocaleString("en-US"); |
| } |
| |
| function pct(n) { |
| return `${Math.round(n)}%`; |
| } |
| |
| function titleCase(skill) { |
| return skill |
| .split(" ") |
| .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) |
| .join(" "); |
| } |
| |
| function buildMatches(userSkills) { |
| return CAREERS.map((career) => { |
| const matched = career.skills.filter((s) => userSkills.includes(s)); |
| const missing = career.skills.filter((s) => !userSkills.includes(s)); |
| const fit = career.skills.length ? (matched.length / career.skills.length) * 100 : 0; |
| return { |
| role: career.role, |
| fit, |
| matched, |
| missing, |
| baseSalary: career.baseSalary, |
| demand: career.demand, |
| }; |
| }).sort((a, b) => (b.fit + b.demand / 10) - (a.fit + a.demand / 10)); |
| } |
| |
| function renderMatchesTable(rows) { |
| matchesBody.innerHTML = rows.map((r) => { |
| return `<tr> |
| <td>${r.role}</td> |
| <td>${pct(r.fit)}</td> |
| <td>${r.matched.length ? r.matched.map(titleCase).join(", ") : "None yet"}</td> |
| <td>${r.missing.length ? r.missing.map(titleCase).join(", ") : "Ready now"}</td> |
| <td>${fmtMoney(r.baseSalary)}</td> |
| <td>${pct(r.demand)}</td> |
| </tr>`; |
| }).join(""); |
| } |
| |
| function runSalarySim(bestCareer) { |
| const steps = []; |
| let salary = 0; |
| for (let i = 0; i < BONUS.length; i++) { |
| if (i === 0) { |
| steps.push({ |
| step: 1, |
| action: `Choose career: ${bestCareer.role}`, |
| career: bestCareer.role, |
| salary: 0, |
| reward: BONUS[0], |
| feedback: "Good", |
| }); |
| } else { |
| const gain = (bestCareer.baseSalary / 12) * BONUS[i]; |
| salary += gain; |
| steps.push({ |
| step: i + 1, |
| action: "Work in role", |
| career: bestCareer.role, |
| salary, |
| reward: BONUS[i], |
| feedback: "Good", |
| }); |
| } |
| } |
| return steps; |
| } |
| |
| function renderStepsTable(steps) { |
| stepsBody.innerHTML = steps.map((s) => { |
| return `<tr> |
| <td>${s.step}</td> |
| <td>${s.action}</td> |
| <td>${s.career}</td> |
| <td>${fmtMoney(s.salary)}</td> |
| <td>${s.reward}</td> |
| <td>${s.feedback}</td> |
| </tr>`; |
| }).join(""); |
| } |
| |
| function buildSummary(userSkills, topRows, steps) { |
| const best = topRows[0]; |
| const nextBest = topRows[1]; |
| const finalSalary = steps[steps.length - 1].salary; |
| const missingText = best.missing.length ? best.missing.map(titleCase).join(", ") : "You already meet the listed skills"; |
| return [ |
| `Best fit today: ${best.role}`, |
| `Why this role: ${pct(best.fit)} skill match and ${pct(best.demand)} market demand in this demo.`, |
| `Skills you already have: ${best.matched.length ? best.matched.map(titleCase).join(", ") : "None yet"}`, |
| `Top gaps to close: ${missingText}`, |
| `Simulated earnings after ramp-up: ${fmtMoney(finalSalary)}`, |
| `Updated skill profile: ${userSkills.length ? userSkills.map(titleCase).join(", ") : "None listed"}`, |
| `Next best option: ${nextBest ? nextBest.role : "N/A"}`, |
| `Simulation result: Good`, |
| ].join("\\n"); |
| } |
| |
| async function callOpenEnvRoutes(skillText) { |
| try { |
| await fetch("/openenv/reset", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: "{}", |
| }); |
| } catch (_) {} |
| try { |
| await fetch("/openenv/step", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ action: { query: skillText } }), |
| }); |
| } catch (_) {} |
| } |
| |
| async function run() { |
| const raw = skillsInput.value.trim(); |
| if (!raw) { |
| setEmptyState(); |
| updateRunButtonState(); |
| return; |
| } |
| const userSkills = parseSkills(raw); |
| const rows = buildMatches(userSkills); |
| const steps = runSalarySim(rows[0]); |
| renderMatchesTable(rows); |
| renderStepsTable(steps); |
| recommendation.textContent = buildSummary(userSkills, rows, steps); |
| await callOpenEnvRoutes(raw || "career guidance"); |
| } |
| |
| runBtn.addEventListener("click", run); |
| skillsInput.addEventListener("input", updateRunButtonState); |
| document.querySelectorAll(".chip").forEach((chip) => { |
| chip.addEventListener("click", () => { |
| skillsInput.value = chip.dataset.example || ""; |
| updateRunButtonState(); |
| }); |
| }); |
| |
| setEmptyState(); |
| updateRunButtonState(); |
| </script> |
| </body> |
| </html> |
| """ |
|
|
|
|
| @app.get("/", response_class=HTMLResponse) |
| def root() -> HTMLResponse: |
| logger.info("UI root endpoint hit.") |
| return HTMLResponse(content=UI_HTML) |
|
|
|
|
| @app.api_route("/reset", methods=["GET", "POST"]) |
| def reset_compat(body: dict[str, Any] | None = None) -> dict[str, Any]: |
| payload = body or {} |
| env = CareerEnvironment() |
| obs = env.reset( |
| seed=payload.get("seed"), |
| episode_id=payload.get("episode_id"), |
| ) |
| return { |
| "observation": obs.model_dump(), |
| "reward": obs.reward, |
| "done": obs.done, |
| } |
|
|
|
|
| @app.api_route("/state", methods=["GET", "POST"]) |
| def state_compat() -> dict[str, Any]: |
| env = CareerEnvironment() |
| return env.state.model_dump() |
|
|
|
|
| @app.get("/schema") |
| def schema_compat() -> dict[str, Any]: |
| return { |
| "action": CareerAction.model_json_schema(), |
| "observation": CareerObservation.model_json_schema(), |
| "state": CareerState.model_json_schema(), |
| } |
|
|
|
|
| @app.api_route("/step", methods=["POST"]) |
| def step_compat(body: dict[str, Any]) -> dict[str, Any]: |
| action_payload = body.get("action") |
| if isinstance(action_payload, dict): |
| query = str(action_payload.get("query", "")) |
| else: |
| query = str(body.get("query", "")) |
|
|
| env = CareerEnvironment() |
| obs = env.step(CareerAction(query=query), timeout_s=body.get("timeout_s")) |
| return { |
| "observation": obs.model_dump(), |
| "reward": obs.reward, |
| "done": obs.done, |
| } |
|
|
|
|
| def main() -> None: |
| logger.info("Starting uvicorn server.app:app on 0.0.0.0:8000") |
| uvicorn.run("server.app:app", host="0.0.0.0", port=8000) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|