| """FastAPI server exposing HelpdeskEnv over HTTP and a lightweight dashboard UI.""" |
|
|
| from typing import Any, Dict, Optional |
|
|
| from fastapi import FastAPI |
| from fastapi.responses import HTMLResponse |
| from pydantic import BaseModel |
| import uvicorn |
|
|
| from ..environment import HelpdeskEnv |
| from ..graders.score_utils import ensure_open_unit_interval |
| from ..models import Action, Reward |
|
|
| app = FastAPI(title="Helpdesk OpenEnv") |
| _env: Optional[HelpdeskEnv] = None |
|
|
| UI_HTML = """<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| <title>UPI Banking Support Environment</title> |
| <style> |
| :root { |
| --bg: #f6f1e8; |
| --panel: rgba(255, 251, 245, 0.92); |
| --panel-strong: #fff9f0; |
| --ink: #1f1c17; |
| --muted: #6e6459; |
| --line: rgba(62, 47, 33, 0.14); |
| --accent: #156f63; |
| --accent-2: #d66b2d; |
| --accent-3: #2f5d9a; |
| --success: #2f8f57; |
| --danger: #b94133; |
| --shadow: 0 22px 60px rgba(75, 52, 27, 0.12); |
| --radius: 24px; |
| --radius-sm: 16px; |
| } |
| |
| * { |
| box-sizing: border-box; |
| } |
| |
| body { |
| margin: 0; |
| font-family: "Avenir Next", "Segoe UI", sans-serif; |
| color: var(--ink); |
| background: |
| radial-gradient(circle at top left, rgba(214, 107, 45, 0.18), transparent 32%), |
| radial-gradient(circle at top right, rgba(21, 111, 99, 0.18), transparent 28%), |
| linear-gradient(180deg, #f8f3eb 0%, #efe7d7 100%); |
| min-height: 100vh; |
| } |
| |
| .shell { |
| width: min(1240px, calc(100vw - 32px)); |
| margin: 24px auto 40px; |
| } |
| |
| .hero { |
| background: linear-gradient(135deg, rgba(27, 75, 68, 0.96), rgba(17, 38, 59, 0.96)); |
| color: #fdfaf3; |
| border-radius: 32px; |
| padding: 28px; |
| box-shadow: var(--shadow); |
| position: relative; |
| overflow: hidden; |
| } |
| |
| .hero::after { |
| content: ""; |
| position: absolute; |
| inset: auto -60px -80px auto; |
| width: 240px; |
| height: 240px; |
| border-radius: 50%; |
| background: rgba(255, 214, 102, 0.18); |
| filter: blur(4px); |
| } |
| |
| .hero-top { |
| display: flex; |
| justify-content: space-between; |
| gap: 16px; |
| align-items: start; |
| } |
| |
| h1, h2, h3, p { |
| margin: 0; |
| } |
| |
| .eyebrow { |
| font-size: 12px; |
| text-transform: uppercase; |
| letter-spacing: 0.22em; |
| color: rgba(253, 250, 243, 0.72); |
| margin-bottom: 10px; |
| } |
| |
| .hero h1 { |
| font-size: clamp(34px, 5vw, 58px); |
| line-height: 0.96; |
| max-width: 760px; |
| } |
| |
| .hero p { |
| margin-top: 16px; |
| max-width: 720px; |
| color: rgba(253, 250, 243, 0.78); |
| font-size: 18px; |
| line-height: 1.5; |
| } |
| |
| .hero-links { |
| display: flex; |
| gap: 10px; |
| flex-wrap: wrap; |
| margin-top: 22px; |
| } |
| |
| .pill { |
| display: inline-flex; |
| align-items: center; |
| gap: 8px; |
| padding: 10px 14px; |
| border-radius: 999px; |
| background: rgba(255, 255, 255, 0.12); |
| color: #fff; |
| text-decoration: none; |
| border: 1px solid rgba(255, 255, 255, 0.16); |
| backdrop-filter: blur(10px); |
| } |
| |
| .status-chip { |
| min-width: 170px; |
| padding: 14px 16px; |
| border-radius: 20px; |
| background: rgba(255, 251, 240, 0.12); |
| border: 1px solid rgba(255, 251, 240, 0.18); |
| text-align: right; |
| } |
| |
| .status-chip strong { |
| display: block; |
| font-size: 11px; |
| letter-spacing: 0.18em; |
| text-transform: uppercase; |
| color: rgba(253, 250, 243, 0.7); |
| margin-bottom: 8px; |
| } |
| |
| .status-chip span { |
| font-size: 24px; |
| font-weight: 700; |
| } |
| |
| .grid { |
| display: grid; |
| grid-template-columns: 1.45fr 1fr; |
| gap: 20px; |
| margin-top: 20px; |
| } |
| |
| .card { |
| background: var(--panel); |
| border: 1px solid var(--line); |
| border-radius: var(--radius); |
| box-shadow: var(--shadow); |
| backdrop-filter: blur(16px); |
| padding: 22px; |
| } |
| |
| .section-title { |
| font-size: 14px; |
| text-transform: uppercase; |
| letter-spacing: 0.18em; |
| color: var(--muted); |
| margin-bottom: 16px; |
| } |
| |
| .metrics { |
| display: grid; |
| grid-template-columns: repeat(4, minmax(0, 1fr)); |
| gap: 14px; |
| margin-top: 20px; |
| } |
| |
| .metric { |
| border-radius: 22px; |
| padding: 18px; |
| color: white; |
| min-height: 128px; |
| display: flex; |
| flex-direction: column; |
| justify-content: space-between; |
| } |
| |
| .metric strong { |
| font-size: 12px; |
| letter-spacing: 0.18em; |
| text-transform: uppercase; |
| opacity: 0.9; |
| } |
| |
| .metric span { |
| font-size: 36px; |
| font-weight: 700; |
| line-height: 1; |
| } |
| |
| .metric small { |
| font-size: 14px; |
| opacity: 0.9; |
| } |
| |
| .metric.reward { background: linear-gradient(135deg, #156f63, #2f8f57); } |
| .metric.task { background: linear-gradient(135deg, #ca5e2c, #df8b2a); } |
| .metric.turn { background: linear-gradient(135deg, #3158a6, #4b7ae0); } |
| .metric.done { background: linear-gradient(135deg, #56406f, #79559e); } |
| |
| .message-box, |
| .stream-box, |
| .info-box, |
| .json-box { |
| background: rgba(255, 255, 255, 0.58); |
| border: 1px solid rgba(89, 67, 43, 0.12); |
| border-radius: 20px; |
| padding: 18px; |
| } |
| |
| .message-box { |
| min-height: 160px; |
| } |
| |
| .message-label { |
| font-size: 12px; |
| text-transform: uppercase; |
| letter-spacing: 0.16em; |
| color: var(--muted); |
| margin-bottom: 12px; |
| } |
| |
| .message-text { |
| font-size: 22px; |
| line-height: 1.45; |
| } |
| |
| .hint { |
| margin-top: 16px; |
| padding: 14px 16px; |
| border-radius: 16px; |
| background: rgba(21, 111, 99, 0.1); |
| border: 1px solid rgba(21, 111, 99, 0.22); |
| color: #185046; |
| line-height: 1.5; |
| } |
| |
| .timeline { |
| display: flex; |
| flex-direction: column; |
| gap: 12px; |
| max-height: 360px; |
| overflow: auto; |
| padding-right: 4px; |
| } |
| |
| .bubble { |
| border-radius: 18px; |
| padding: 14px 16px; |
| border: 1px solid rgba(79, 58, 34, 0.1); |
| background: rgba(255, 255, 255, 0.72); |
| } |
| |
| .bubble.agent { |
| background: rgba(214, 107, 45, 0.11); |
| border-color: rgba(214, 107, 45, 0.22); |
| } |
| |
| .bubble.user { |
| background: rgba(21, 111, 99, 0.09); |
| border-color: rgba(21, 111, 99, 0.18); |
| } |
| |
| .bubble strong { |
| display: block; |
| font-size: 11px; |
| letter-spacing: 0.16em; |
| text-transform: uppercase; |
| color: var(--muted); |
| margin-bottom: 8px; |
| } |
| |
| .form-grid { |
| display: grid; |
| grid-template-columns: repeat(2, minmax(0, 1fr)); |
| gap: 14px; |
| } |
| |
| label { |
| display: block; |
| font-size: 13px; |
| letter-spacing: 0.06em; |
| text-transform: uppercase; |
| color: var(--muted); |
| margin-bottom: 8px; |
| } |
| |
| input, select, textarea { |
| width: 100%; |
| border: 1px solid rgba(75, 55, 33, 0.16); |
| background: rgba(255, 255, 255, 0.92); |
| color: var(--ink); |
| border-radius: 16px; |
| padding: 14px 15px; |
| font: inherit; |
| } |
| |
| textarea { |
| min-height: 120px; |
| resize: vertical; |
| } |
| |
| .full { |
| grid-column: 1 / -1; |
| } |
| |
| .button-row { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 12px; |
| margin-top: 18px; |
| } |
| |
| button { |
| border: 0; |
| border-radius: 999px; |
| padding: 14px 18px; |
| font: inherit; |
| font-weight: 700; |
| cursor: pointer; |
| transition: transform 0.15s ease, opacity 0.15s ease; |
| } |
| |
| button:hover { |
| transform: translateY(-1px); |
| } |
| |
| button.primary { |
| background: linear-gradient(135deg, #156f63, #2f8f57); |
| color: white; |
| } |
| |
| button.secondary { |
| background: rgba(49, 93, 154, 0.12); |
| color: #24497d; |
| } |
| |
| button.ghost { |
| background: rgba(31, 28, 23, 0.07); |
| color: var(--ink); |
| } |
| |
| button:disabled { |
| opacity: 0.5; |
| cursor: not-allowed; |
| transform: none; |
| } |
| |
| .mini-grid { |
| display: grid; |
| grid-template-columns: repeat(2, minmax(0, 1fr)); |
| gap: 14px; |
| margin-top: 18px; |
| } |
| |
| .kv { |
| border-radius: 18px; |
| padding: 16px; |
| background: rgba(255, 255, 255, 0.58); |
| border: 1px solid rgba(89, 67, 43, 0.12); |
| min-height: 112px; |
| } |
| |
| .kv strong { |
| display: block; |
| font-size: 11px; |
| letter-spacing: 0.16em; |
| text-transform: uppercase; |
| color: var(--muted); |
| margin-bottom: 10px; |
| } |
| |
| .mono { |
| font-family: "SFMono-Regular", "Menlo", monospace; |
| font-size: 14px; |
| line-height: 1.55; |
| white-space: pre-wrap; |
| word-break: break-word; |
| } |
| |
| .tags { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 8px; |
| } |
| |
| .tag { |
| padding: 8px 12px; |
| border-radius: 999px; |
| background: rgba(49, 93, 154, 0.1); |
| color: #27497a; |
| font-size: 13px; |
| border: 1px solid rgba(49, 93, 154, 0.14); |
| } |
| |
| .muted { |
| color: var(--muted); |
| } |
| |
| .footer-note { |
| margin-top: 18px; |
| color: var(--muted); |
| font-size: 14px; |
| line-height: 1.5; |
| } |
| |
| @media (max-width: 980px) { |
| .grid, |
| .metrics, |
| .form-grid, |
| .mini-grid { |
| grid-template-columns: 1fr; |
| } |
| |
| .hero-top { |
| flex-direction: column; |
| } |
| |
| .status-chip { |
| text-align: left; |
| } |
| } |
| </style> |
| </head> |
| <body> |
| <div class="shell"> |
| <section class="hero"> |
| <div class="hero-top"> |
| <div> |
| <div class="eyebrow">HF Space Dashboard</div> |
| <h1>UPI Banking Support Environment</h1> |
| <p>Run the benchmark like an operator: reset an episode, choose the exact action your agent would take, and inspect the live observation, conversation, and current reward after each step.</p> |
| <div class="hero-links"> |
| <a class="pill" href="/health" target="_blank" rel="noreferrer">Health</a> |
| <a class="pill" href="/docs" target="_blank" rel="noreferrer">API Docs</a> |
| <a class="pill" href="/state" target="_blank" rel="noreferrer">Raw State</a> |
| </div> |
| </div> |
| <div class="status-chip"> |
| <strong>Environment</strong> |
| <span id="env-status">Checking...</span> |
| </div> |
| </div> |
| <div class="metrics"> |
| <div class="metric reward"> |
| <strong>Current Reward</strong> |
| <span id="metric-reward">0.000</span> |
| <small>Most recent reward value</small> |
| </div> |
| <div class="metric task"> |
| <strong>Difficulty</strong> |
| <span id="metric-task">-</span> |
| <small>Current episode track</small> |
| </div> |
| <div class="metric turn"> |
| <strong>Turn</strong> |
| <span id="metric-turn">0</span> |
| <small>Current step count</small> |
| </div> |
| <div class="metric done"> |
| <strong>Status</strong> |
| <span id="metric-done">Idle</span> |
| <small>Episode completion</small> |
| </div> |
| </div> |
| </section> |
| |
| <div class="grid"> |
| <section class="card"> |
| <div class="section-title">Current Ticket</div> |
| <div class="message-box"> |
| <div class="message-label">Customer Message</div> |
| <div class="message-text" id="customer-message">Reset the environment to load a ticket.</div> |
| </div> |
| <div class="hint" id="hint-box">Pick a difficulty, reset the env, then use one of your supported actions. This UI is tuned for classify, FAQ lookup, clarification, reply, escalate, and resolve flows.</div> |
| <div class="mini-grid"> |
| <div class="kv"> |
| <strong>Case</strong> |
| <div class="mono" id="case-id">-</div> |
| </div> |
| <div class="kv"> |
| <strong>Required Slots</strong> |
| <div id="required-slots" class="tags"><span class="muted">No episode loaded yet.</span></div> |
| </div> |
| <div class="kv"> |
| <strong>Available Actions</strong> |
| <div id="available-actions" class="tags"><span class="muted">Reset to populate actions.</span></div> |
| </div> |
| <div class="kv"> |
| <strong>Collected Facts</strong> |
| <div class="mono" id="collected-facts">-</div> |
| </div> |
| </div> |
| </section> |
| |
| <section class="card"> |
| <div class="section-title">Action Console</div> |
| <div class="form-grid"> |
| <div> |
| <label for="difficulty">Difficulty</label> |
| <select id="difficulty"> |
| <option value="easy">Easy</option> |
| <option value="medium">Medium</option> |
| <option value="hard">Hard</option> |
| </select> |
| </div> |
| <div> |
| <label for="action-type">Action Type</label> |
| <select id="action-type"> |
| <option value="classify">classify</option> |
| <option value="lookup_faq">lookup_faq</option> |
| <option value="ask_clarification">ask_clarification</option> |
| <option value="reply">reply</option> |
| <option value="escalate">escalate</option> |
| <option value="resolve_ticket">resolve_ticket</option> |
| </select> |
| </div> |
| <div id="category-wrap"> |
| <label for="category">Category</label> |
| <select id="category"> |
| <option value="">Select category</option> |
| </select> |
| </div> |
| <div id="faq-wrap"> |
| <label for="faq-id">FAQ</label> |
| <select id="faq-id"> |
| <option value="">Select FAQ</option> |
| </select> |
| </div> |
| <div class="full" id="message-wrap"> |
| <label for="message">Message</label> |
| <textarea id="message" placeholder="Write the agent message for clarification, reply, or escalation."></textarea> |
| </div> |
| </div> |
| <div class="button-row"> |
| <button id="step-btn" class="primary">Execute Step</button> |
| <button id="reset-btn" class="secondary">Reset</button> |
| <button id="state-btn" class="ghost">Refresh State</button> |
| </div> |
| <div class="footer-note" id="action-help">Use `classify` for easy tasks, `lookup_faq` or `escalate` for medium tickets, and clarification plus reply plus resolve for hard tickets.</div> |
| </section> |
| </div> |
| |
| <div class="grid"> |
| <section class="card"> |
| <div class="section-title">Conversation Timeline</div> |
| <div class="timeline" id="timeline"> |
| <div class="bubble"> |
| <strong>Waiting</strong> |
| Reset the environment to start an episode. |
| </div> |
| </div> |
| </section> |
| |
| <section class="card"> |
| <div class="section-title">Step Details</div> |
| <div class="mini-grid"> |
| <div class="kv"> |
| <strong>Current Reward Breakdown</strong> |
| <div class="mono" id="reward-breakdown">-</div> |
| </div> |
| <div class="kv"> |
| <strong>Episode Info</strong> |
| <div class="mono" id="episode-info">-</div> |
| </div> |
| <div class="kv full"> |
| <strong>Observation Snapshot</strong> |
| <div class="mono" id="observation-view">-</div> |
| </div> |
| </div> |
| </section> |
| </div> |
| </div> |
| |
| <script> |
| const categoryChoices = [ |
| "payment_failure", |
| "refund_delay", |
| "fraud_complaint", |
| "kyc_account_restriction", |
| "upi_pin_or_bank_linking" |
| ]; |
| |
| const faqChoices = [ |
| ["faq_001", "Payment failed, money debited"], |
| ["faq_002", "Merchant unpaid after debit"], |
| ["faq_003", "Delayed refund"], |
| ["faq_004", "Merchant says refund complete"], |
| ["faq_005", "Unauthorized payment"], |
| ["faq_006", "KYC pending restriction"], |
| ["faq_007", "KYC submitted but blocked"], |
| ["faq_008", "UPI PIN setup/reset issue"], |
| ["faq_009", "Bank account linking issue"], |
| ["faq_010", "Scam collect request"] |
| ]; |
| |
| const state = { |
| observation: null, |
| reward: { |
| value: 0, |
| correctness: 0, |
| safety: 0, |
| resolution: 0, |
| efficiency: 0, |
| penalties: 0, |
| done: false |
| }, |
| done: false, |
| info: {} |
| }; |
| |
| const els = { |
| envStatus: document.getElementById("env-status"), |
| metricReward: document.getElementById("metric-reward"), |
| metricTask: document.getElementById("metric-task"), |
| metricTurn: document.getElementById("metric-turn"), |
| metricDone: document.getElementById("metric-done"), |
| customerMessage: document.getElementById("customer-message"), |
| hintBox: document.getElementById("hint-box"), |
| caseId: document.getElementById("case-id"), |
| requiredSlots: document.getElementById("required-slots"), |
| availableActions: document.getElementById("available-actions"), |
| collectedFacts: document.getElementById("collected-facts"), |
| timeline: document.getElementById("timeline"), |
| rewardBreakdown: document.getElementById("reward-breakdown"), |
| episodeInfo: document.getElementById("episode-info"), |
| observationView: document.getElementById("observation-view"), |
| difficulty: document.getElementById("difficulty"), |
| actionType: document.getElementById("action-type"), |
| category: document.getElementById("category"), |
| faqId: document.getElementById("faq-id"), |
| message: document.getElementById("message"), |
| categoryWrap: document.getElementById("category-wrap"), |
| faqWrap: document.getElementById("faq-wrap"), |
| messageWrap: document.getElementById("message-wrap"), |
| actionHelp: document.getElementById("action-help"), |
| resetBtn: document.getElementById("reset-btn"), |
| stepBtn: document.getElementById("step-btn"), |
| stateBtn: document.getElementById("state-btn") |
| }; |
| |
| function populateSelect(select, values, formatter) { |
| const base = select.id === "faq-id" |
| ? '<option value="">Select FAQ</option>' |
| : '<option value="">Select category</option>'; |
| select.innerHTML = base + values.map((value) => { |
| if (Array.isArray(value)) { |
| return `<option value="${value[0]}">${value[0]} · ${value[1]}</option>`; |
| } |
| const label = formatter ? formatter(value) : value; |
| return `<option value="${value}">${label}</option>`; |
| }).join(""); |
| } |
| |
| function renderTags(container, items) { |
| if (!items || items.length === 0) { |
| container.innerHTML = '<span class="muted">None</span>'; |
| return; |
| } |
| container.innerHTML = items.map((item) => `<span class="tag">${item}</span>`).join(""); |
| } |
| |
| function safeJson(value) { |
| return JSON.stringify(value, null, 2); |
| } |
| |
| function updateActionFields() { |
| const action = els.actionType.value; |
| const showCategory = action === "classify"; |
| const showFaq = action === "lookup_faq"; |
| const showMessage = ["ask_clarification", "reply", "escalate"].includes(action); |
| |
| els.categoryWrap.style.display = showCategory ? "block" : "none"; |
| els.faqWrap.style.display = showFaq ? "block" : "none"; |
| els.messageWrap.style.display = showMessage ? "block" : "none"; |
| |
| const helpMap = { |
| classify: "Predict the banking issue category for the current ticket.", |
| lookup_faq: "Pick the FAQ entry your agent wants to use for guidance or routing.", |
| ask_clarification: "Ask the customer for more details before attempting a resolution.", |
| reply: "Send a safe support response to the user.", |
| escalate: "Escalate the ticket when the issue needs manual handling or fraud review.", |
| resolve_ticket: "Close the ticket when the case appears resolved." |
| }; |
| els.actionHelp.textContent = helpMap[action] || ""; |
| } |
| |
| function updateView() { |
| const obs = state.observation; |
| const reward = state.reward || {}; |
| const info = state.info || {}; |
| |
| els.metricReward.textContent = Number(reward.value || 0).toFixed(3); |
| els.metricTask.textContent = obs?.track || info.task_id || "-"; |
| els.metricTurn.textContent = String(obs?.turn_number ?? info.turn_number ?? 0); |
| els.metricDone.textContent = state.done ? "Done" : (obs ? "Running" : "Idle"); |
| |
| els.customerMessage.textContent = obs?.customer_message || "Reset the environment to load a ticket."; |
| els.caseId.textContent = obs?.case_id || info.ticket_id || "-"; |
| renderTags(els.requiredSlots, obs?.required_slots || []); |
| renderTags(els.availableActions, obs?.available_actions || []); |
| |
| const collected = obs?.known_facts?.collected_slots || {}; |
| els.collectedFacts.textContent = Object.keys(collected).length ? safeJson(collected) : "-"; |
| |
| const facts = obs?.known_facts || {}; |
| const hintParts = []; |
| if (facts.clarification_received) hintParts.push("clarification received"); |
| if (facts.faq_retrieved) hintParts.push("faq retrieved"); |
| if (facts.issue_resolved) hintParts.push("issue resolved"); |
| els.hintBox.textContent = hintParts.length |
| ? `Live env signals: ${hintParts.join(" · ")}.` |
| : "No progress flags are active yet. Choose the next action based on the ticket and available workflow."; |
| |
| const history = obs?.conversation_history || []; |
| if (!history.length) { |
| els.timeline.innerHTML = '<div class="bubble"><strong>Waiting</strong>No actions yet. Reset the env, then execute a step.</div>'; |
| } else { |
| els.timeline.innerHTML = history.map((entry, index) => { |
| const role = entry.role || "event"; |
| return `<div class="bubble ${role}"><strong>${role} ${index + 1}</strong>${entry.content || "-"}</div>`; |
| }).join(""); |
| } |
| |
| els.rewardBreakdown.textContent = safeJson({ |
| current_reward: Number(reward.value || 0).toFixed(3), |
| correctness: reward.correctness ?? 0, |
| safety: reward.safety ?? 0, |
| resolution: reward.resolution ?? 0, |
| efficiency: reward.efficiency ?? 0, |
| penalties: reward.penalties ?? 0 |
| }); |
| els.episodeInfo.textContent = safeJson({ |
| done: state.done, |
| ...info |
| }); |
| els.observationView.textContent = obs ? safeJson(obs) : "-"; |
| } |
| |
| async function handleResponse(response) { |
| if (!response.ok) { |
| const text = await response.text(); |
| throw new Error(text || `Request failed with status ${response.status}`); |
| } |
| return response.json(); |
| } |
| |
| async function checkHealth() { |
| try { |
| const res = await fetch("/health"); |
| const data = await handleResponse(res); |
| els.envStatus.textContent = data.status || "healthy"; |
| } catch (error) { |
| els.envStatus.textContent = "unavailable"; |
| } |
| } |
| |
| async function resetEnv() { |
| els.resetBtn.disabled = true; |
| try { |
| const res = await fetch("/reset", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ task_id: els.difficulty.value }) |
| }); |
| const data = await handleResponse(res); |
| state.observation = data.observation; |
| state.reward = data.reward; |
| state.done = Boolean(data.done); |
| state.info = data.info || {}; |
| updateView(); |
| } finally { |
| els.resetBtn.disabled = false; |
| } |
| } |
| |
| async function fetchState() { |
| els.stateBtn.disabled = true; |
| try { |
| const res = await fetch("/state"); |
| const data = await handleResponse(res); |
| state.observation = data.observation; |
| updateView(); |
| } catch (error) { |
| alert(`State unavailable: ${error.message}`); |
| } finally { |
| els.stateBtn.disabled = false; |
| } |
| } |
| |
| function buildActionPayload() { |
| const actionType = els.actionType.value; |
| const action = { action_type: actionType }; |
| |
| if (actionType === "classify" && els.category.value) { |
| action.category = els.category.value; |
| } |
| if (actionType === "lookup_faq" && els.faqId.value) { |
| action.faq_id = els.faqId.value; |
| } |
| if (["ask_clarification", "reply", "escalate"].includes(actionType) && els.message.value.trim()) { |
| action.message = els.message.value.trim(); |
| } |
| return { action }; |
| } |
| |
| async function executeStep() { |
| els.stepBtn.disabled = true; |
| try { |
| const res = await fetch("/step", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify(buildActionPayload()) |
| }); |
| const data = await handleResponse(res); |
| state.observation = data.observation; |
| state.reward = data.reward; |
| state.done = Boolean(data.done); |
| state.info = data.info || {}; |
| updateView(); |
| } catch (error) { |
| alert(`Step failed: ${error.message}`); |
| } finally { |
| els.stepBtn.disabled = false; |
| } |
| } |
| |
| populateSelect(els.category, categoryChoices); |
| populateSelect(els.faqId, faqChoices); |
| updateActionFields(); |
| updateView(); |
| checkHealth(); |
| |
| els.actionType.addEventListener("change", updateActionFields); |
| els.resetBtn.addEventListener("click", resetEnv); |
| els.stepBtn.addEventListener("click", executeStep); |
| els.stateBtn.addEventListener("click", fetchState); |
| </script> |
| </body> |
| </html> |
| """ |
|
|
|
|
| def get_env() -> HelpdeskEnv: |
| global _env |
| if _env is None: |
| _env = HelpdeskEnv() |
| return _env |
|
|
|
|
| class ResetBody(BaseModel): |
| task_id: str = "easy" |
|
|
|
|
| def _zero_reward() -> Dict[str, Any]: |
| return Reward( |
| value=ensure_open_unit_interval(0.0), |
| correctness=ensure_open_unit_interval(0.0), |
| safety=ensure_open_unit_interval(1.0), |
| resolution=ensure_open_unit_interval(0.0), |
| efficiency=ensure_open_unit_interval(0.0), |
| penalties=0.0, |
| done=False, |
| info={}, |
| ).model_dump() |
|
|
|
|
| @app.get("/health") |
| def health() -> Dict[str, str]: |
| return {"status": "healthy"} |
|
|
|
|
| @app.get("/", response_class=HTMLResponse) |
| def root() -> HTMLResponse: |
| return HTMLResponse(UI_HTML) |
|
|
|
|
| @app.post("/reset") |
| def reset(body: ResetBody = ResetBody()) -> Dict[str, Any]: |
| obs = get_env().reset(body.task_id) |
| return { |
| "observation": obs.model_dump(), |
| "reward": _zero_reward(), |
| "done": False, |
| "info": {}, |
| } |
|
|
|
|
| @app.post("/step") |
| def step(body: Dict[str, Any]) -> Dict[str, Any]: |
| action = Action(**body["action"]) |
| obs, reward, done, info = get_env().step(action) |
| return { |
| "observation": obs.model_dump(), |
| "reward": reward.model_dump(), |
| "done": done, |
| "info": info, |
| } |
|
|
|
|
| @app.get("/state") |
| def state() -> Dict[str, Any]: |
| obs = get_env().state() |
| return {"observation": obs.model_dump()} |
|
|
|
|
| def main() -> None: |
| uvicorn.run("helpdesk_env.server.app:app", host="0.0.0.0", port=8000) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|