HelpDesk / server /app.py
Freakdivi's picture
updating model.py
913b593
"""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()