Spaces:
Sleeping
Sleeping
| """FastAPI server exposing the CustomerSupportEnv over HTTP. | |
| Endpoints: | |
| GET / β browser debug UI (HTML) | |
| GET /docs β interactive OpenAPI (Swagger UI, default theme) | |
| POST /reset β start a new episode | |
| POST /step β apply an action | |
| GET /state β read current state without advancing | |
| POST /close β release episode resources | |
| POST /inference β run the full LLM inference loop | |
| GET /health β liveness probe (JSON) | |
| """ | |
| from __future__ import annotations | |
| import io | |
| import json | |
| import os | |
| import time | |
| from contextlib import redirect_stdout | |
| from collections import defaultdict, deque | |
| from pathlib import Path | |
| from typing import Any, Literal | |
| from fastapi import FastAPI, Header, HTTPException, Request | |
| from fastapi.responses import HTMLResponse, JSONResponse | |
| from pydantic import BaseModel, ConfigDict, Field | |
| from env.environment import CustomerSupportEnv | |
| from models.action import Action | |
| from sandbox.env_adapter import SandboxEnv | |
| _OPENAPI_TAGS = [ | |
| { | |
| "name": "Environment", | |
| "description": ( | |
| "Episode lifecycle for the customer-support RL environment: " | |
| "call **reset** before **step**; use **state** to inspect without advancing." | |
| ), | |
| }, | |
| { | |
| "name": "Interface", | |
| "description": "Browser UI for manual debugging (HTML, not JSON).", | |
| }, | |
| { | |
| "name": "System", | |
| "description": "Health checks and optional batch inference (requires server env configuration).", | |
| }, | |
| ] | |
| app = FastAPI( | |
| title="OpenEnv Enterprise Incident Command Center", | |
| version="2.0.0", | |
| openapi_tags=_OPENAPI_TAGS, | |
| description=( | |
| "Deterministic enterprise incident simulation for [OpenEnv](https://github.com/open-env). " | |
| "Supports backward-compatible ticket mode and a full incident command lifecycle " | |
| "with enterprise tools, policy drift, and multi-step remediation." | |
| ), | |
| ) | |
| _DEFAULT_SESSION_ID = "default" | |
| _API_KEY = os.environ.get("OPENENV_API_KEY", "").strip() | |
| _RATE_LIMIT_PER_MIN = int(os.environ.get("OPENENV_RATE_LIMIT_PER_MIN", "120")) | |
| _AUDIT_LOG_PATH = os.environ.get("OPENENV_AUDIT_LOG_PATH", "").strip() | |
| _AUTH_EXEMPT_PATHS = frozenset(["/", "/docs", "/openapi.json", "/redoc", "/health"]) | |
| EnvLike = CustomerSupportEnv | SandboxEnv | |
| _USE_SANDBOX = os.environ.get("OPENENV_SANDBOX", "false").strip().lower() in {"1", "true", "yes", "on"} | |
| _SANDBOX_CLUSTER_URL = os.environ.get("OPENENV_SANDBOX_CLUSTER_URL", "http://localhost").strip() | |
| _SANDBOX_CHAOS_URL = os.environ.get("OPENENV_SANDBOX_CHAOS_URL", "http://localhost:6660").strip() | |
| def _create_env() -> EnvLike: | |
| if _USE_SANDBOX: | |
| return SandboxEnv(cluster_base_url=_SANDBOX_CLUSTER_URL, chaos_url=_SANDBOX_CHAOS_URL) | |
| return CustomerSupportEnv() | |
| _SESSION_ENVS: dict[str, EnvLike] = {_DEFAULT_SESSION_ID: _create_env()} | |
| _RATE_LIMIT_WINDOWS: dict[str, deque[float]] = defaultdict(deque) | |
| def _resolve_session_id( | |
| request_value: str | None, | |
| header_value: str | None, | |
| ) -> str: | |
| session_id = (request_value or header_value or _DEFAULT_SESSION_ID).strip() | |
| return session_id or _DEFAULT_SESSION_ID | |
| def _audit_event(payload: dict[str, Any]) -> None: | |
| line = json.dumps(payload, separators=(",", ":"), ensure_ascii=True) | |
| if _AUDIT_LOG_PATH: | |
| path = Path(_AUDIT_LOG_PATH) | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| with path.open("a", encoding="utf-8") as handle: | |
| handle.write(line + "\n") | |
| async def _get_env(session_id: str, *, create_if_missing: bool = True) -> EnvLike: | |
| env = _SESSION_ENVS.get(session_id) | |
| if env is None and create_if_missing: | |
| env = _create_env() | |
| _SESSION_ENVS[session_id] = env | |
| if env is None: | |
| raise HTTPException(status_code=404, detail=f"Unknown session_id '{session_id}'") | |
| return env | |
| async def _close_session(session_id: str) -> None: | |
| env = _SESSION_ENVS.get(session_id) | |
| if env is None: | |
| return | |
| await env.close() | |
| if session_id != _DEFAULT_SESSION_ID: | |
| _SESSION_ENVS.pop(session_id, None) | |
| async def security_and_audit_middleware(request: Request, call_next): # type: ignore[no-untyped-def] | |
| started = time.perf_counter() | |
| client_host = request.client.host if request.client else "unknown" | |
| req_api_key = request.headers.get("X-API-Key", "") | |
| req_session_id = request.headers.get("X-Session-ID", _DEFAULT_SESSION_ID) | |
| status_code = 500 | |
| try: | |
| if _API_KEY and request.url.path not in _AUTH_EXEMPT_PATHS: | |
| if req_api_key != _API_KEY: | |
| status_code = 401 | |
| return JSONResponse(status_code=401, content={"detail": "Unauthorized"}) | |
| if _RATE_LIMIT_PER_MIN > 0 and request.url.path not in _AUTH_EXEMPT_PATHS: | |
| now = time.monotonic() | |
| rate_key = req_api_key or client_host | |
| window = _RATE_LIMIT_WINDOWS[rate_key] | |
| while window and now - window[0] > 60.0: | |
| window.popleft() | |
| if len(window) >= _RATE_LIMIT_PER_MIN: | |
| status_code = 429 | |
| return JSONResponse(status_code=429, content={"detail": "Rate limit exceeded"}) | |
| window.append(now) | |
| response = await call_next(request) | |
| status_code = response.status_code | |
| return response | |
| finally: | |
| elapsed_ms = round((time.perf_counter() - started) * 1000, 2) | |
| _audit_event( | |
| { | |
| "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), | |
| "method": request.method, | |
| "path": request.url.path, | |
| "status": status_code, | |
| "elapsed_ms": elapsed_ms, | |
| "client": client_host, | |
| "session_id": req_session_id, | |
| } | |
| ) | |
| # ββ request / response schemas βββββββββββββββββββββββββββββββββββββββββββββββ | |
| class ResetRequest(BaseModel): | |
| """Start a new episode; same inputs yield the same deterministic scenario.""" | |
| model_config = ConfigDict( | |
| json_schema_extra={ | |
| "examples": [ | |
| {"seed": 0, "difficulty": "easy", "mode": "ticket"}, | |
| {"seed": 0, "difficulty": "easy", "mode": "incident"}, | |
| {"seed": 0, "difficulty": "nightmare", "mode": "incident"}, | |
| { | |
| "seed": 0, | |
| "difficulty": "hard", | |
| "mode": "incident", | |
| "drill_mode": True, | |
| "drill_seed": 7, | |
| }, | |
| {"seed": 0, "difficulty": None, "mode": "ticket"}, | |
| ] | |
| } | |
| ) | |
| seed: int = Field( | |
| default=0, | |
| description="Index into the ticket pool (modulo pool size). With `difficulty` omitted, indexes the combined bank.", | |
| ) | |
| mode: Literal["ticket", "incident"] = Field( | |
| default="ticket", | |
| description="Episode mode. `ticket` preserves legacy triage behavior; `incident` enables incident lifecycle simulation.", | |
| ) | |
| difficulty: Literal["easy", "medium", "hard", "nightmare"] | None = Field( | |
| default=None, | |
| description=( | |
| "Difficulty filter. Ticket mode supports easy/medium/hard; " | |
| "incident mode supports easy/medium/hard/nightmare." | |
| ), | |
| ) | |
| session_id: str | None = Field( | |
| default=None, | |
| description="Optional episode session key. If omitted, uses the default shared session.", | |
| ) | |
| drill_mode: bool = Field( | |
| default=False, | |
| description=( | |
| "Sandbox-only: enable deterministic failure curriculum drill events " | |
| "during incident episodes." | |
| ), | |
| ) | |
| drill_seed: int | None = Field( | |
| default=None, | |
| description="Sandbox-only deterministic seed override for drill schedule.", | |
| ) | |
| class StepRequest(BaseModel): | |
| """Apply one agent action. Must match the current phase (see `observation.phase` and `available_actions`).""" | |
| model_config = ConfigDict( | |
| json_schema_extra={ | |
| "example": { | |
| "action": { | |
| "action_type": "classify", | |
| "category": "general_inquiry", | |
| "priority": "medium", | |
| } | |
| } | |
| } | |
| ) | |
| action: Action = Field( | |
| description=( | |
| "Discriminated union on `action_type`: classify, route, respond, escalate, request_info, " | |
| "resolve, check_monitoring, probe_service, fetch_logs, fetch_user_data, check_billing, " | |
| "query_kb, check_policy, query_incident_history, follow_runbook_step, apply_fix, verify_fix, " | |
| "rollback_fix, notify_stakeholders, write_postmortem, update_kb. " | |
| "Wrong shape -> **422**; wrong phase -> **200** with a penalty in `reward`." | |
| ), | |
| ) | |
| session_id: str | None = Field( | |
| default=None, | |
| description="Optional episode session key. Must match a prior reset session to avoid cross-episode overlap.", | |
| ) | |
| class EnvResponse(BaseModel): | |
| """Observation and reward after reset, step, or state.""" | |
| observation: dict[str, Any] | None = Field( | |
| description="Ticket + phase + history. `null` only from GET /state when reset has not been called yet.", | |
| ) | |
| reward: float = Field(description="Reward for the last transition; 0 on reset and on GET /state.") | |
| done: bool = Field(description="True after a terminal resolve (episode finished).") | |
| info: dict[str, Any] = Field( | |
| default_factory=dict, | |
| description="Diagnostics (e.g. difficulty, last feedback, reward breakdown).", | |
| ) | |
| class InferenceResponse(BaseModel): | |
| """Output from running the bundled LLM inference script once.""" | |
| stdout: str = Field(description="Captured stdout from `inference.run()` (validator-style log lines).") | |
| score: float = Field(description="Parsed from the final `[END]` line when present; else 0.0.") | |
| success: bool = Field(description="Parsed from the final `[END]` line when present; else false.") | |
| # ββ helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _result_to_dict(result: Any, *, session_id: str | None = None) -> dict[str, Any]: | |
| info = dict(result.info) | |
| if session_id is not None: | |
| info["session_id"] = session_id | |
| return { | |
| "observation": result.observation.model_dump(), | |
| "reward": result.reward, | |
| "done": result.done, | |
| "info": info, | |
| } | |
| _DEBUG_UI_HTML = """<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Customer Support Command Center</title> | |
| <style> | |
| :root { | |
| --bg: #141517; | |
| --surface: #25262b; | |
| --border: #373a40; | |
| --text: #e9ecef; | |
| --muted: #868e96; | |
| --accent: #4c6ef5; | |
| --exec: #e8590c; | |
| --exec-hover: #fd7e14; | |
| --hint-bg: #1b4332; | |
| --hint-border: #2b8a3e; | |
| --card-blue: #1864ab; | |
| --card-orange: #d9480f; | |
| --card-green: #2b8a3e; | |
| --card-grey: #495057; | |
| --danger: #fa5252; | |
| --mono: ui-monospace, "Cascadia Code", "SF Mono", Menlo, monospace; | |
| } | |
| * { box-sizing: border-box; } | |
| html { color-scheme: dark; } | |
| body { | |
| margin: 0; | |
| min-height: 100vh; | |
| font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| line-height: 1.45; | |
| } | |
| .wrap { max-width: 56rem; margin: 0 auto; padding: 1.5rem 1rem 3rem; } | |
| h1 { font-size: 1.35rem; font-weight: 700; margin: 0 0 0.25rem; letter-spacing: -0.02em; } | |
| .sub { color: var(--muted); font-size: 0.875rem; margin: 0 0 1.25rem; } | |
| .hint-bar { | |
| background: var(--hint-bg); | |
| border: 1px solid var(--hint-border); | |
| color: #b2f2bb; | |
| font-size: 0.8rem; | |
| padding: 0.5rem 0.75rem; | |
| border-radius: 6px; | |
| margin-bottom: 1rem; | |
| } | |
| .panel { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| padding: 1rem 1.15rem; | |
| margin-bottom: 1rem; | |
| } | |
| .panel h2 { | |
| margin: 0 0 0.75rem; | |
| font-size: 0.95rem; | |
| font-weight: 600; | |
| color: var(--text); | |
| } | |
| label { font-size: 0.75rem; color: var(--muted); display: block; margin-bottom: 0.3rem; } | |
| label .hint { font-weight: 400; color: #5c636a; } | |
| select, input[type="text"], input[type="number"], textarea { | |
| width: 100%; | |
| padding: 0.5rem 0.55rem; | |
| border-radius: 6px; | |
| border: 1px solid var(--border); | |
| background: #1a1b1e; | |
| color: var(--text); | |
| font-size: 0.85rem; | |
| } | |
| select { | |
| cursor: pointer; | |
| appearance: auto; | |
| min-height: 2.25rem; | |
| } | |
| textarea { font-family: var(--mono); font-size: 0.8rem; min-height: 4.5rem; resize: vertical; } | |
| /* Dark scrollbars β match theme (textarea JSON preview, pre blocks, timeline) */ | |
| textarea, | |
| pre { | |
| scrollbar-width: thin; | |
| scrollbar-color: #5c636a #141517; | |
| } | |
| textarea::-webkit-scrollbar, | |
| pre::-webkit-scrollbar { | |
| width: 8px; | |
| height: 8px; | |
| } | |
| textarea::-webkit-scrollbar-corner, | |
| pre::-webkit-scrollbar-corner { | |
| background: #141517; | |
| } | |
| textarea::-webkit-scrollbar-track, | |
| pre::-webkit-scrollbar-track { | |
| background: #141517; | |
| border-radius: 4px; | |
| } | |
| textarea::-webkit-scrollbar-thumb, | |
| pre::-webkit-scrollbar-thumb { | |
| background: #5c636a; | |
| border-radius: 4px; | |
| border: 2px solid #141517; | |
| } | |
| textarea::-webkit-scrollbar-thumb:hover, | |
| pre::-webkit-scrollbar-thumb:hover { | |
| background: #868e96; | |
| } | |
| /* Number inputs: drop default light steppers (seed still editable) */ | |
| input[type="number"] { | |
| -moz-appearance: textfield; | |
| appearance: textfield; | |
| } | |
| input[type="number"]::-webkit-outer-spin-button, | |
| input[type="number"]::-webkit-inner-spin-button { | |
| -webkit-appearance: none; | |
| margin: 0; | |
| } | |
| .form-grid { | |
| display: grid; | |
| gap: 0.75rem; | |
| margin-top: 0.75rem; | |
| } | |
| @media (min-width: 560px) { | |
| .form-grid.cols-2 { grid-template-columns: 1fr 1fr; } | |
| } | |
| .field-group { display: none; } | |
| .field-group.active { display: block; } | |
| .btn-row { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; } | |
| button { | |
| border: none; | |
| padding: 0.55rem 1rem; | |
| border-radius: 8px; | |
| font-size: 0.8rem; | |
| font-weight: 600; | |
| cursor: pointer; | |
| text-transform: uppercase; | |
| letter-spacing: 0.03em; | |
| } | |
| button.exec { background: var(--exec); color: #fff; } | |
| button.exec:hover:not(:disabled) { background: var(--exec-hover); } | |
| button.exec:disabled { | |
| background: #495057; | |
| color: #868e96; | |
| cursor: not-allowed; | |
| opacity: 0.65; | |
| } | |
| button.secondary { background: #495057; color: #fff; } | |
| button.secondary:hover { background: #5c636a; } | |
| .metrics { | |
| display: grid; | |
| grid-template-columns: repeat(2, 1fr); | |
| gap: 0.65rem; | |
| } | |
| @media (min-width: 720px) { .metrics { grid-template-columns: repeat(4, 1fr); } } | |
| .metric { | |
| border-radius: 8px; | |
| padding: 0.75rem 0.85rem; | |
| color: #fff; | |
| } | |
| .metric .m-label { font-size: 0.65rem; text-transform: uppercase; opacity: 0.9; letter-spacing: 0.06em; } | |
| .metric .m-val { font-size: 1.35rem; font-weight: 700; margin-top: 0.2rem; font-variant-numeric: tabular-nums; } | |
| .metric.blue { background: linear-gradient(135deg, var(--card-blue), #1c7ed6); } | |
| .metric.orange { background: linear-gradient(135deg, var(--card-orange), #e8590c); } | |
| .metric.green { background: linear-gradient(135deg, var(--card-green), #37b24d); } | |
| .metric.grey { background: linear-gradient(135deg, #495057, #6c757d); } | |
| pre { | |
| margin: 0; | |
| padding: 0.65rem 0.75rem; | |
| background: #1a1b1e; | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| font-family: var(--mono); | |
| font-size: 0.72rem; | |
| overflow-x: auto; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| } | |
| .tag { font-size: 0.72rem; color: var(--muted); margin-bottom: 0.25rem; } | |
| #status { font-size: 0.8rem; min-height: 1.25rem; margin: 0.5rem 0; } | |
| #status.err { color: var(--danger); } | |
| /* Fixed toasts β errors visible without scrolling past the timeline */ | |
| #toast-root { | |
| position: fixed; | |
| top: 0.75rem; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| z-index: 10000; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: stretch; | |
| gap: 0.45rem; | |
| max-width: min(38rem, calc(100vw - 1.5rem)); | |
| pointer-events: none; | |
| } | |
| .toast { | |
| pointer-events: auto; | |
| margin: 0; | |
| padding: 0.65rem 1rem; | |
| border-radius: 8px; | |
| font-size: 0.82rem; | |
| line-height: 1.45; | |
| box-shadow: 0 10px 28px rgba(0, 0, 0, 0.5); | |
| border: 1px solid rgba(250, 82, 82, 0.45); | |
| background: #3b1219; | |
| color: #ffc9c9; | |
| opacity: 0; | |
| transform: translateY(-0.4rem); | |
| transition: opacity 0.22s ease, transform 0.22s ease; | |
| cursor: pointer; | |
| word-break: break-word; | |
| } | |
| .toast.toast-visible { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| .toast:focus { | |
| outline: 2px solid var(--accent); | |
| outline-offset: 2px; | |
| } | |
| .json-preview label { margin-top: 0.75rem; } | |
| #actionJson { min-height: 5rem; opacity: 0.92; } | |
| footer { margin-top: 1.25rem; font-size: 0.72rem; color: var(--muted); } | |
| footer a { color: var(--accent); } | |
| /* Current ticket (reference-style card) */ | |
| h2.section-title { | |
| font-size: 1rem; | |
| font-weight: 700; | |
| color: #f8f9fa; | |
| margin: 0 0 0.5rem; | |
| letter-spacing: -0.01em; | |
| } | |
| .ticket-panel { | |
| background: #1e1f23 !important; | |
| border-color: #2c2e33 !important; | |
| padding: 1rem 1.1rem 1.1rem !important; | |
| } | |
| .ticket-pills { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0.45rem; | |
| margin-bottom: 0.85rem; | |
| } | |
| .pill { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 0.35rem; | |
| padding: 0.28rem 0.65rem; | |
| border-radius: 5px; | |
| background: #2f3138; | |
| border: 1px solid #3d4049; | |
| font-size: 0.72rem; | |
| color: #dee2e6; | |
| } | |
| .pill kbd { | |
| font-family: inherit; | |
| font-weight: 600; | |
| color: #adb5bd; | |
| font-size: 0.68rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.04em; | |
| } | |
| .pill span.val { font-weight: 600; color: #fff; } | |
| .pill.mode-ticket { background: #243d56; border-color: #2f5f87; } | |
| .pill.mode-incident { background: #4a2532; border-color: #7f344a; } | |
| .pill.phase-unclassified { background: #3f3f46; border-color: #5c5f66; } | |
| .pill.phase-classified { background: #2b3a67; border-color: #3f58a1; } | |
| .pill.phase-routed { background: #2f4c33; border-color: #3f7b48; } | |
| .pill.phase-responding { background: #4f4123; border-color: #8c6d30; } | |
| .pill.phase-escalated { background: #4a2b2b; border-color: #8b3f3f; } | |
| .pill.phase-resolved { background: #2f4f3a; border-color: #3f8f58; } | |
| .pill.phase-triage { background: #3d2e58; border-color: #654094; } | |
| .pill.phase-investigation { background: #274d5a; border-color: #2f7f96; } | |
| .pill.phase-response { background: #5a4220; border-color: #9c7431; } | |
| .pill.phase-resolution { background: #25523c; border-color: #2f8a5b; } | |
| .ticket-msg-wrap { | |
| background: #121214; | |
| border: 1px solid #2c2e33; | |
| border-radius: 6px; | |
| padding: 0.75rem 0.9rem 1rem; | |
| } | |
| .ticket-msg-label { | |
| font-size: 0.68rem; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.06em; | |
| color: #868e96; | |
| margin-bottom: 0.5rem; | |
| } | |
| .ticket-msg-body { | |
| font-size: 0.95rem; | |
| line-height: 1.55; | |
| color: #f1f3f5; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| } | |
| .ticket-msg-body.empty { color: #5c636a; font-style: italic; } | |
| /* Action timeline */ | |
| .timeline-panel { | |
| background: #252830 !important; | |
| border-color: #3d4150 !important; | |
| } | |
| .timeline-scroll { | |
| max-height: 18rem; | |
| overflow-y: auto; | |
| overflow-x: hidden; | |
| scrollbar-gutter: stable; | |
| scrollbar-width: thin; | |
| scrollbar-color: #5c636a #141517; | |
| } | |
| .timeline-scroll::-webkit-scrollbar { width: 8px; } | |
| .timeline-scroll::-webkit-scrollbar-track { background: #141517; border-radius: 4px; } | |
| .timeline-scroll::-webkit-scrollbar-thumb { | |
| background: #5c636a; | |
| border-radius: 4px; | |
| border: 2px solid #141517; | |
| } | |
| .timeline-scroll::-webkit-scrollbar-thumb:hover { background: #868e96; } | |
| .timeline-list { display: flex; flex-direction: column; gap: 0.5rem; } | |
| .timeline-item { | |
| background: #121214; | |
| border: 1px solid #2c2e33; | |
| border-radius: 6px; | |
| padding: 0.65rem 0.85rem; | |
| overflow: hidden; | |
| } | |
| .timeline-row { | |
| display: flex; | |
| align-items: flex-start; | |
| justify-content: space-between; | |
| gap: 1rem; | |
| } | |
| .tl-main { min-width: 0; flex: 1; } | |
| .tl-step-num { | |
| font-weight: 700; | |
| color: #e9ecef; | |
| font-size: 0.82rem; | |
| } | |
| .tl-action-name { | |
| font-size: 0.82rem; | |
| color: #ced4da; | |
| font-weight: 500; | |
| } | |
| .tl-action-chip { | |
| display: inline-flex; | |
| align-items: center; | |
| border-radius: 999px; | |
| font-size: 0.62rem; | |
| padding: 0.1rem 0.45rem; | |
| margin-left: 0.45rem; | |
| border: 1px solid transparent; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| font-weight: 700; | |
| color: #f8f9fa; | |
| } | |
| .tl-action-chip.family-diagnose { background: #21354d; border-color: #345a84; } | |
| .tl-action-chip.family-respond { background: #4b351a; border-color: #8e6026; } | |
| .tl-action-chip.family-resolve { background: #1e4c35; border-color: #2f8a5b; } | |
| .tl-action-chip.family-governance { background: #47264d; border-color: #7e4090; } | |
| .tl-action-chip.family-legacy { background: #3f3f46; border-color: #5e6068; } | |
| .tl-reward { | |
| font-size: 0.85rem; | |
| font-weight: 700; | |
| font-variant-numeric: tabular-nums; | |
| color: #fff; | |
| flex-shrink: 0; | |
| } | |
| .tl-reward.neg { color: #ff8787; } | |
| .tl-reward.pos { color: #8ce99a; } | |
| .tl-feedback { | |
| margin-top: 0.45rem; | |
| padding-top: 0.45rem; | |
| border-top: 1px solid #2c2e33; | |
| font-size: 0.72rem; | |
| line-height: 1.4; | |
| color: #868e96; | |
| } | |
| .timeline-empty { | |
| text-align: center; | |
| padding: 1.25rem 0.75rem; | |
| color: #868e96; | |
| font-size: 0.82rem; | |
| background: #121214; | |
| border-radius: 6px; | |
| border: 1px dashed #3d4049; | |
| } | |
| .page-head { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: flex-start; | |
| justify-content: space-between; | |
| gap: 0.75rem; | |
| margin-bottom: 0.25rem; | |
| } | |
| .page-head-text { flex: 1; min-width: 12rem; } | |
| .page-head-text h1 { margin-bottom: 0.25rem; } | |
| .page-head-actions { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0.45rem; | |
| align-items: center; | |
| } | |
| a.btn-mini { | |
| display: inline-block; | |
| padding: 0.35rem 0.65rem; | |
| border-radius: 6px; | |
| font-size: 0.72rem; | |
| font-weight: 600; | |
| text-decoration: none; | |
| text-transform: uppercase; | |
| letter-spacing: 0.04em; | |
| background: #fdfd96; | |
| color: #111; | |
| border: none; | |
| cursor: pointer; | |
| font-family: inherit; | |
| line-height: 1.2; | |
| } | |
| a.btn-mini:hover { filter: brightness(1.06); } | |
| a.btn-mini:focus-visible { | |
| outline: 2px solid var(--accent); | |
| outline-offset: 2px; | |
| } | |
| .quick-presets { | |
| margin-top: 0.85rem; | |
| border: 1px dashed #3f444f; | |
| border-radius: 8px; | |
| padding: 0.65rem 0.75rem; | |
| background: #1a1b1e; | |
| } | |
| .preset-row { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0.4rem; | |
| margin-top: 0.3rem; | |
| } | |
| .preset-btn { | |
| border: 1px solid #3d4150; | |
| background: #2a2d35; | |
| color: #e9ecef; | |
| padding: 0.28rem 0.55rem; | |
| border-radius: 999px; | |
| font-size: 0.68rem; | |
| font-weight: 600; | |
| text-transform: lowercase; | |
| letter-spacing: 0.01em; | |
| cursor: pointer; | |
| } | |
| .preset-btn:hover:not(:disabled) { | |
| border-color: #5c7cfa; | |
| background: #30374a; | |
| } | |
| .preset-btn:disabled { | |
| opacity: 0.45; | |
| cursor: not-allowed; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="toast-root" aria-live="assertive" aria-relevant="additions"></div> | |
| <div class="wrap"> | |
| <header class="page-head"> | |
| <div class="page-head-text"> | |
| <h1>Enterprise Incident Command Center</h1> | |
| <p class="sub">Ticket triage + incident operations in one console Β· | |
| <a href="/health" style="color:var(--accent)">/health</a></p> | |
| </div> | |
| <div class="page-head-actions"> | |
| <a class="btn-mini" href="/docs" target="_blank" rel="noopener noreferrer">Docs</a> | |
| </div> | |
| </header> | |
| <div class="metrics panel" style="padding:0.85rem;margin-bottom:1rem;background:#1a1b1e;border-style:dashed"> | |
| <div class="metric blue"> | |
| <div class="m-label">Last step reward</div> | |
| <div class="m-val" id="mLastReward">β</div> | |
| </div> | |
| <div class="metric orange"> | |
| <div class="m-label">Total reward</div> | |
| <div class="m-val" id="mTotalReward">β</div> | |
| </div> | |
| <div class="metric green"> | |
| <div class="m-label">Score</div> | |
| <div class="m-val" id="mScore">β</div> | |
| </div> | |
| <div class="metric grey"> | |
| <div class="m-label">Status</div> | |
| <div class="m-val" id="mStatus" style="font-size:1rem">β</div> | |
| </div> | |
| </div> | |
| <h2 class="section-title">Current Context</h2> | |
| <div class="panel ticket-panel"> | |
| <div class="ticket-pills" id="ticketPills"></div> | |
| <div class="ticket-msg-wrap"> | |
| <div class="ticket-msg-label">Customer Message</div> | |
| <div class="ticket-msg-body empty" id="ticketBody">Reset or load state to show the active ticket.</div> | |
| </div> | |
| </div> | |
| <div class="panel"> | |
| <div class="hint-bar"> | |
| Choose <strong>mode</strong> on reset: <strong>ticket</strong> for legacy triage or <strong>incident</strong> for EICC workflow. | |
| In sandbox mode, optional <strong>drill mode</strong> injects deterministic mid-episode failures. | |
| Use <strong>Get state</strong> to inspect <code>available_actions</code>; unavailable actions are auto-disabled after reset/step. | |
| </div> | |
| <h2>Step-by-step action</h2> | |
| <label for="actionType">Action type <span class="hint">(matches POST /step JSON)</span></label> | |
| <select id="actionType"> | |
| <optgroup label="Ticket Actions"> | |
| <option value="classify">classify</option> | |
| <option value="route">route</option> | |
| <option value="respond">respond</option> | |
| <option value="escalate">escalate</option> | |
| <option value="request_info">request_info</option> | |
| <option value="resolve">resolve</option> | |
| </optgroup> | |
| <optgroup label="Incident Investigation"> | |
| <option value="check_monitoring">check_monitoring</option> | |
| <option value="probe_service">probe_service</option> | |
| <option value="fetch_logs">fetch_logs</option> | |
| <option value="fetch_user_data">fetch_user_data</option> | |
| <option value="check_billing">check_billing</option> | |
| <option value="query_kb">query_kb</option> | |
| <option value="check_policy">check_policy</option> | |
| <option value="query_incident_history">query_incident_history</option> | |
| <option value="follow_runbook_step">follow_runbook_step</option> | |
| </optgroup> | |
| <optgroup label="Incident Response & Resolution"> | |
| <option value="apply_fix">apply_fix</option> | |
| <option value="verify_fix">verify_fix</option> | |
| <option value="rollback_fix">rollback_fix</option> | |
| <option value="notify_stakeholders">notify_stakeholders</option> | |
| <option value="write_postmortem">write_postmortem</option> | |
| <option value="update_kb">update_kb</option> | |
| </optgroup> | |
| </select> | |
| <div id="fgClassify" class="field-group form-grid cols-2"> | |
| <div> | |
| <label for="clsCategory">Category</label> | |
| <select id="clsCategory"> | |
| <option value="billing">billing</option> | |
| <option value="bug_report">bug_report</option> | |
| <option value="feature_request">feature_request</option> | |
| <option value="account_access">account_access</option> | |
| <option value="general_inquiry" selected>general_inquiry</option> | |
| <option value="cancellation">cancellation</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label for="clsPriority">Priority</label> | |
| <select id="clsPriority"> | |
| <option value="low">low</option> | |
| <option value="medium" selected>medium</option> | |
| <option value="high">high</option> | |
| <option value="critical">critical</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div id="fgRoute" class="field-group form-grid"> | |
| <div> | |
| <label for="rteDept">Department</label> | |
| <select id="rteDept"> | |
| <option value="billing">billing</option> | |
| <option value="technical">technical</option> | |
| <option value="account">account</option> | |
| <option value="general" selected>general</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div id="fgRespond" class="field-group form-grid"> | |
| <div> | |
| <label for="rspTone">Tone</label> | |
| <select id="rspTone"> | |
| <option value="formal" selected>formal</option> | |
| <option value="empathetic">empathetic</option> | |
| <option value="concise">concise</option> | |
| </select> | |
| </div> | |
| <div style="grid-column:1/-1"> | |
| <label for="rspText">Response text</label> | |
| <textarea id="rspText" spellcheck="false">Thank you for contacting us. We are reviewing your request.</textarea> | |
| </div> | |
| </div> | |
| <div id="fgEscalate" class="field-group form-grid cols-2"> | |
| <div> | |
| <label for="escTeam">Target team</label> | |
| <select id="escTeam"> | |
| <option value="l2_support" selected>l2_support</option> | |
| <option value="engineering">engineering</option> | |
| <option value="management">management</option> | |
| </select> | |
| </div> | |
| <div style="grid-column:1/-1"> | |
| <label for="escReason">Reason</label> | |
| <textarea id="escReason" spellcheck="false">Requires specialist review per policy.</textarea> | |
| </div> | |
| </div> | |
| <div id="fgRequestInfo" class="field-group form-grid"> | |
| <div> | |
| <label for="reqQ">Question to customer</label> | |
| <textarea id="reqQ" spellcheck="false">Could you share your account email and approximate time of the issue?</textarea> | |
| </div> | |
| </div> | |
| <div id="fgResolve" class="field-group form-grid"> | |
| <div style="grid-column:1/-1"> | |
| <label for="rsvSummary">Resolution summary</label> | |
| <textarea id="rsvSummary" spellcheck="false">Issue reviewed and resolved to customer satisfaction.</textarea> | |
| </div> | |
| <div> | |
| <label for="rsvComp">Offered compensation <span class="hint">(optional, empty = omit)</span></label> | |
| <input type="number" id="rsvComp" step="any" placeholder="e.g. 29.99" /> | |
| </div> | |
| </div> | |
| <div id="fgCheckMonitoring" class="field-group form-grid"> | |
| <div> | |
| <label for="monService">Service name <span class="hint">(optional)</span></label> | |
| <select id="monService"> | |
| <option value="">all services</option> | |
| <option value="auth">auth</option> | |
| <option value="database">database</option> | |
| <option value="payments">payments</option> | |
| <option value="analytics">analytics</option> | |
| <option value="notifications">notifications</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div id="fgProbeService" class="field-group form-grid cols-2"> | |
| <div> | |
| <label for="probeService">Service name</label> | |
| <select id="probeService"> | |
| <option value="auth">auth</option> | |
| <option value="database">database</option> | |
| <option value="payments" selected>payments</option> | |
| <option value="analytics">analytics</option> | |
| <option value="notifications">notifications</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label for="probeType">Check type</label> | |
| <select id="probeType"> | |
| <option value="logs">logs</option> | |
| <option value="resources">resources</option> | |
| <option value="connections" selected>connections</option> | |
| <option value="config">config</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div id="fgFetchLogs" class="field-group form-grid cols-2"> | |
| <div> | |
| <label for="logsService">Service name</label> | |
| <select id="logsService"> | |
| <option value="auth">auth</option> | |
| <option value="database">database</option> | |
| <option value="payments" selected>payments</option> | |
| <option value="analytics">analytics</option> | |
| <option value="notifications">notifications</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label for="logsRange">Time range</label> | |
| <select id="logsRange"> | |
| <option value="last_5m">last_5m</option> | |
| <option value="last_15m" selected>last_15m</option> | |
| <option value="last_1h">last_1h</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div id="fgFetchUserData" class="field-group form-grid"> | |
| <div> | |
| <label for="userCustomerId">Customer ID</label> | |
| <input type="text" id="userCustomerId" value="cust-acme-001" /> | |
| </div> | |
| </div> | |
| <div id="fgCheckBilling" class="field-group form-grid"> | |
| <div> | |
| <label for="billingCustomerId">Customer ID</label> | |
| <input type="text" id="billingCustomerId" value="cust-acme-001" /> | |
| </div> | |
| </div> | |
| <div id="fgQueryKb" class="field-group form-grid"> | |
| <div> | |
| <label for="kbQuery">KB query</label> | |
| <textarea id="kbQuery" spellcheck="false">payment timeout after auth token refresh</textarea> | |
| </div> | |
| </div> | |
| <div id="fgCheckPolicy" class="field-group form-grid"> | |
| <div> | |
| <label for="policyType">Policy type</label> | |
| <select id="policyType"> | |
| <option value="refund">refund</option> | |
| <option value="escalation">escalation</option> | |
| <option value="sla" selected>sla</option> | |
| <option value="compensation">compensation</option> | |
| <option value="communication">communication</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div id="fgQueryIncidentHistory" class="field-group form-grid cols-2"> | |
| <div style="grid-column:1/-1"> | |
| <label for="historyQuery">History query</label> | |
| <textarea id="historyQuery" spellcheck="false">payments failing after auth token cache expiry</textarea> | |
| </div> | |
| <div> | |
| <label for="historyService">Service filter <span class="hint">(optional)</span></label> | |
| <select id="historyService"> | |
| <option value="">none</option> | |
| <option value="auth">auth</option> | |
| <option value="database">database</option> | |
| <option value="payments" selected>payments</option> | |
| <option value="analytics">analytics</option> | |
| <option value="notifications">notifications</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div id="fgFollowRunbookStep" class="field-group form-grid cols-2"> | |
| <div> | |
| <label for="runbookId">Runbook ID</label> | |
| <input type="text" id="runbookId" value="rb-payment-cascade" /> | |
| </div> | |
| <div> | |
| <label for="runbookStepIdx">Step index</label> | |
| <input type="number" id="runbookStepIdx" value="0" step="1" /> | |
| </div> | |
| </div> | |
| <div id="fgApplyFix" class="field-group form-grid cols-2"> | |
| <div> | |
| <label for="fixService">Service name</label> | |
| <select id="fixService"> | |
| <option value="auth">auth</option> | |
| <option value="database">database</option> | |
| <option value="payments" selected>payments</option> | |
| <option value="analytics">analytics</option> | |
| <option value="notifications">notifications</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label for="fixType">Fix type</label> | |
| <input type="text" id="fixType" value="clear_token_cache" /> | |
| </div> | |
| </div> | |
| <div id="fgVerifyFix" class="field-group form-grid"> | |
| <div> | |
| <label for="verifyService">Service name</label> | |
| <select id="verifyService"> | |
| <option value="auth">auth</option> | |
| <option value="database">database</option> | |
| <option value="payments" selected>payments</option> | |
| <option value="analytics">analytics</option> | |
| <option value="notifications">notifications</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div id="fgRollbackFix" class="field-group form-grid"> | |
| <div> | |
| <label for="rollbackService">Service name</label> | |
| <select id="rollbackService"> | |
| <option value="auth">auth</option> | |
| <option value="database">database</option> | |
| <option value="payments" selected>payments</option> | |
| <option value="analytics">analytics</option> | |
| <option value="notifications">notifications</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div id="fgNotifyStakeholders" class="field-group form-grid cols-2"> | |
| <div> | |
| <label for="notifyStakeholder">Stakeholder</label> | |
| <select id="notifyStakeholder"> | |
| <option value="vp_engineering" selected>vp_engineering</option> | |
| <option value="legal">legal</option> | |
| <option value="support_lead">support_lead</option> | |
| <option value="all">all</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label for="notifyUrgency">Urgency</label> | |
| <select id="notifyUrgency"> | |
| <option value="info">info</option> | |
| <option value="warning" selected>warning</option> | |
| <option value="critical">critical</option> | |
| </select> | |
| </div> | |
| <div style="grid-column:1/-1"> | |
| <label for="notifyMessage">Message</label> | |
| <textarea id="notifyMessage" spellcheck="false">Investigation in progress. Mitigation underway and customer impact is being contained.</textarea> | |
| </div> | |
| </div> | |
| <div id="fgWritePostmortem" class="field-group form-grid cols-2"> | |
| <div style="grid-column:1/-1"> | |
| <label for="pmSummary">Summary</label> | |
| <textarea id="pmSummary" spellcheck="false">Payments requests failed intermittently due to stale auth token cache, triggering cascading retries.</textarea> | |
| </div> | |
| <div style="grid-column:1/-1"> | |
| <label for="pmRootCause">Root cause description</label> | |
| <textarea id="pmRootCause" spellcheck="false">Auth token cache was not invalidated after key rotation, causing downstream authorization failures.</textarea> | |
| </div> | |
| <div> | |
| <label for="pmRemediation">Remediation steps <span class="hint">(comma or newline separated)</span></label> | |
| <textarea id="pmRemediation" spellcheck="false">flush token cache, restart auth workers, verify success metrics</textarea> | |
| </div> | |
| <div> | |
| <label for="pmPrevention">Prevention measures <span class="hint">(comma or newline separated)</span></label> | |
| <textarea id="pmPrevention" spellcheck="false">add key-rotation canary, alert on auth mismatch, runbook update</textarea> | |
| </div> | |
| </div> | |
| <div id="fgUpdateKb" class="field-group form-grid cols-2"> | |
| <div style="grid-column:1/-1"> | |
| <label for="kbTitle">Article title</label> | |
| <input type="text" id="kbTitle" value="Fixing payment failures after auth key rotation" /> | |
| </div> | |
| <div style="grid-column:1/-1"> | |
| <label for="kbContent">Article content</label> | |
| <textarea id="kbContent" spellcheck="false">If payment errors spike after key rotation, check auth token cache consistency, flush cache, and verify service health before rollback.</textarea> | |
| </div> | |
| <div style="grid-column:1/-1"> | |
| <label for="kbTags">Tags <span class="hint">(comma separated)</span></label> | |
| <input type="text" id="kbTags" value="payments, auth, incident-response" /> | |
| </div> | |
| </div> | |
| <div class="json-preview"> | |
| <label for="actionJson">Request body preview <code>{"action": β¦}</code></label> | |
| <textarea id="actionJson" readonly spellcheck="false"></textarea> | |
| </div> | |
| <div class="form-grid cols-2" style="margin-top:0.85rem"> | |
| <div> | |
| <label for="resetMode">Reset Β· mode</label> | |
| <select id="resetMode"> | |
| <option value="ticket" selected>ticket</option> | |
| <option value="incident">incident</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label for="resetDifficulty">Reset Β· difficulty</label> | |
| <select id="resetDifficulty"> | |
| <option value="">Any</option> | |
| <option value="easy">easy only</option> | |
| <option value="medium">medium only</option> | |
| <option value="hard">hard only</option> | |
| <option value="nightmare">nightmare (incident only)</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label for="resetSeed">Reset Β· seed</label> | |
| <input type="number" id="resetSeed" value="0" step="1" /> | |
| </div> | |
| <div> | |
| <label for="resetDrillMode">Reset Β· drill mode (sandbox)</label> | |
| <select id="resetDrillMode"> | |
| <option value="false" selected>disabled</option> | |
| <option value="true">enabled</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label for="resetDrillSeed">Reset Β· drill seed <span class="hint">(optional)</span></label> | |
| <input type="number" id="resetDrillSeed" step="1" placeholder="uses reset seed if empty" /> | |
| </div> | |
| </div> | |
| <div class="btn-row"> | |
| <button type="button" class="exec" id="btnStep">Execute step</button> | |
| <button type="button" class="secondary" id="btnReset">Reset</button> | |
| <button type="button" class="secondary" id="btnState">Get state</button> | |
| </div> | |
| <div class="quick-presets"> | |
| <div class="tag">quick actions for current phase</div> | |
| <div class="preset-row" id="presetRow"> | |
| <button type="button" class="preset-btn" disabled>reset to load suggestions</button> | |
| </div> | |
| </div> | |
| </div> | |
| <h2 class="section-title">Action Timeline</h2> | |
| <div class="panel timeline-panel"> | |
| <div class="timeline-scroll"> | |
| <div class="timeline-list" id="timelineList"> | |
| <div class="timeline-empty">No actions yet. Run <strong>Reset</strong>, then <strong>Execute step</strong>.</div> | |
| </div> | |
| </div> | |
| </div> | |
| <p id="status"></p> | |
| <div class="panel"> | |
| <div class="tag">observation</div> | |
| <pre id="outObs">β</pre> | |
| </div> | |
| <div class="panel grid" style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem"> | |
| <div> | |
| <div class="tag">reward (response)</div> | |
| <pre id="outReward">β</pre> | |
| </div> | |
| <div> | |
| <div class="tag">done</div> | |
| <pre id="outDone">β</pre> | |
| </div> | |
| </div> | |
| <div class="panel"> | |
| <div class="tag">info</div> | |
| <pre id="outInfo">β</pre> | |
| </div> | |
| <footer>Built from dropdowns β <code>POST /step</code>. Toggle action type to edit fields.</footer> | |
| </div> | |
| <script> | |
| (function () { | |
| const $ = (id) => document.getElementById(id); | |
| const toastRoot = $("toast-root"); | |
| const status = $("status"); | |
| const outObs = $("outObs"); | |
| const outReward = $("outReward"); | |
| const outDone = $("outDone"); | |
| const outInfo = $("outInfo"); | |
| const mLast = $("mLastReward"); | |
| const mTotal = $("mTotalReward"); | |
| const mScore = $("mScore"); | |
| const mStat = $("mStatus"); | |
| const presetRow = $("presetRow"); | |
| let totalReward = 0; | |
| let episodeActive = false; | |
| let sessionId = null; | |
| function syncStepButton() { | |
| $("btnStep").disabled = !episodeActive; | |
| } | |
| function pretty(obj) { | |
| return JSON.stringify(obj, null, 2); | |
| } | |
| function requestHeaders(includeJson) { | |
| const headers = {}; | |
| if (includeJson) headers["Content-Type"] = "application/json"; | |
| if (sessionId) headers["X-Session-ID"] = sessionId; | |
| return headers; | |
| } | |
| function parseListInput(raw) { | |
| if (!raw) return []; | |
| return raw | |
| .split(/\\n|,/) | |
| .map((part) => part.trim()) | |
| .filter(Boolean); | |
| } | |
| function buildAction() { | |
| const t = $("actionType").value; | |
| switch (t) { | |
| case "classify": | |
| return { | |
| action_type: "classify", | |
| category: $("clsCategory").value, | |
| priority: $("clsPriority").value, | |
| }; | |
| case "route": | |
| return { action_type: "route", department: $("rteDept").value }; | |
| case "respond": | |
| return { | |
| action_type: "respond", | |
| response_text: $("rspText").value.trim() || " ", | |
| tone: $("rspTone").value, | |
| }; | |
| case "escalate": | |
| return { | |
| action_type: "escalate", | |
| reason: $("escReason").value.trim() || "Escalation.", | |
| target_team: $("escTeam").value, | |
| }; | |
| case "request_info": | |
| return { | |
| action_type: "request_info", | |
| question_to_customer: $("reqQ").value.trim() || "Please provide more details.", | |
| }; | |
| case "resolve": { | |
| const raw = $("rsvComp").value.trim(); | |
| let comp = null; | |
| if (raw !== "") { | |
| const n = Number(raw); | |
| comp = Number.isFinite(n) ? n : null; | |
| } | |
| const o = { | |
| action_type: "resolve", | |
| resolution_summary: $("rsvSummary").value.trim() || "Resolved.", | |
| }; | |
| if (comp !== null) o.offered_compensation = comp; | |
| return o; | |
| } | |
| case "check_monitoring": { | |
| const service = $("monService").value; | |
| const payload = { action_type: "check_monitoring" }; | |
| if (service) payload.service_name = service; | |
| return payload; | |
| } | |
| case "probe_service": | |
| return { | |
| action_type: "probe_service", | |
| service_name: $("probeService").value, | |
| check_type: $("probeType").value, | |
| }; | |
| case "fetch_logs": | |
| return { | |
| action_type: "fetch_logs", | |
| service_name: $("logsService").value, | |
| time_range: $("logsRange").value, | |
| }; | |
| case "fetch_user_data": | |
| return { | |
| action_type: "fetch_user_data", | |
| customer_id: $("userCustomerId").value.trim() || "cust-acme-001", | |
| }; | |
| case "check_billing": | |
| return { | |
| action_type: "check_billing", | |
| customer_id: $("billingCustomerId").value.trim() || "cust-acme-001", | |
| }; | |
| case "query_kb": | |
| return { | |
| action_type: "query_kb", | |
| query: $("kbQuery").value.trim() || "payment timeout", | |
| }; | |
| case "check_policy": | |
| return { | |
| action_type: "check_policy", | |
| policy_type: $("policyType").value, | |
| }; | |
| case "query_incident_history": { | |
| const payload = { | |
| action_type: "query_incident_history", | |
| query: $("historyQuery").value.trim() || "payments incident", | |
| }; | |
| const service = $("historyService").value; | |
| if (service) payload.service_filter = service; | |
| return payload; | |
| } | |
| case "follow_runbook_step": { | |
| const stepRaw = parseInt($("runbookStepIdx").value, 10); | |
| return { | |
| action_type: "follow_runbook_step", | |
| runbook_id: $("runbookId").value.trim() || "rb-payment-cascade", | |
| step_index: Number.isFinite(stepRaw) ? stepRaw : 0, | |
| }; | |
| } | |
| case "apply_fix": | |
| return { | |
| action_type: "apply_fix", | |
| service_name: $("fixService").value, | |
| fix_type: $("fixType").value.trim() || "clear_token_cache", | |
| }; | |
| case "verify_fix": | |
| return { | |
| action_type: "verify_fix", | |
| service_name: $("verifyService").value, | |
| }; | |
| case "rollback_fix": | |
| return { | |
| action_type: "rollback_fix", | |
| service_name: $("rollbackService").value, | |
| }; | |
| case "notify_stakeholders": | |
| return { | |
| action_type: "notify_stakeholders", | |
| stakeholder: $("notifyStakeholder").value, | |
| message: $("notifyMessage").value.trim() || "Incident update in progress.", | |
| urgency: $("notifyUrgency").value, | |
| }; | |
| case "write_postmortem": | |
| return { | |
| action_type: "write_postmortem", | |
| summary: $("pmSummary").value.trim() || "Incident summary pending.", | |
| root_cause_description: $("pmRootCause").value.trim() || "Root cause pending.", | |
| remediation_steps: parseListInput($("pmRemediation").value), | |
| prevention_measures: parseListInput($("pmPrevention").value), | |
| }; | |
| case "update_kb": | |
| return { | |
| action_type: "update_kb", | |
| article_title: $("kbTitle").value.trim() || "Incident update", | |
| content: $("kbContent").value.trim() || "Pending content.", | |
| tags: parseListInput($("kbTags").value), | |
| }; | |
| default: | |
| return { action_type: "classify", category: "general_inquiry", priority: "medium" }; | |
| } | |
| } | |
| function syncJsonPreview() { | |
| $("actionJson").value = pretty(buildAction()); | |
| } | |
| function showFieldGroups() { | |
| const t = $("actionType").value; | |
| document.querySelectorAll(".field-group").forEach((el) => { | |
| el.classList.toggle("active", el.id === "fg" + capitalize(t)); | |
| }); | |
| syncJsonPreview(); | |
| } | |
| function capitalize(s) { | |
| return s.split("_").map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(""); | |
| } | |
| ["actionType", "clsCategory", "clsPriority", "rteDept", "rspTone", "rspText", | |
| "escTeam", "escReason", "reqQ", "rsvSummary", "rsvComp", "monService", | |
| "probeService", "probeType", "logsService", "logsRange", "userCustomerId", | |
| "billingCustomerId", "kbQuery", "policyType", "historyQuery", "historyService", | |
| "runbookId", "runbookStepIdx", "fixService", "fixType", "verifyService", | |
| "rollbackService", "notifyStakeholder", "notifyMessage", "notifyUrgency", | |
| "pmSummary", "pmRootCause", "pmRemediation", "pmPrevention", "kbTitle", | |
| "kbContent", "kbTags", "resetDrillMode", "resetDrillSeed"].forEach((id) => { | |
| const el = $(id); | |
| if (el) el.addEventListener("input", syncJsonPreview); | |
| if (el) el.addEventListener("change", syncJsonPreview); | |
| }); | |
| $("actionType").addEventListener("change", showFieldGroups); | |
| function actionSummary(actionTaken) { | |
| if (!actionTaken) return "β"; | |
| try { | |
| const o = JSON.parse(actionTaken); | |
| if (o && typeof o.action_type === "string") return o.action_type; | |
| } catch (e) { /* ignore */ } | |
| return actionTaken.length > 56 ? actionTaken.slice(0, 53) + "β¦" : actionTaken; | |
| } | |
| function phaseClass(phase) { | |
| if (!phase) return ""; | |
| return "phase-" + String(phase).toLowerCase(); | |
| } | |
| function extractActionType(actionTaken) { | |
| if (!actionTaken) return ""; | |
| try { | |
| const parsed = JSON.parse(actionTaken); | |
| if (parsed && typeof parsed.action_type === "string") return parsed.action_type; | |
| } catch (e) { /* ignore */ } | |
| return String(actionTaken).trim(); | |
| } | |
| function actionFamily(actionType) { | |
| const diagnose = new Set([ | |
| "check_monitoring", "probe_service", "fetch_logs", "fetch_user_data", "check_billing", | |
| "query_kb", "check_policy", "query_incident_history", "follow_runbook_step", | |
| ]); | |
| const respond = new Set(["apply_fix", "rollback_fix", "notify_stakeholders", "respond", "escalate", "request_info"]); | |
| const resolve = new Set(["verify_fix", "resolve", "write_postmortem", "update_kb"]); | |
| if (diagnose.has(actionType)) return "diagnose"; | |
| if (respond.has(actionType)) return "respond"; | |
| if (resolve.has(actionType)) return "resolve"; | |
| return "legacy"; | |
| } | |
| function renderPresets(obs) { | |
| if (!presetRow) return; | |
| presetRow.innerHTML = ""; | |
| const fallback = ["classify", "route", "respond", "resolve"]; | |
| let suggestions = fallback; | |
| if (obs && obs.mode === "incident") { | |
| const byPhase = { | |
| triage: ["check_monitoring", "query_kb", "query_incident_history", "follow_runbook_step"], | |
| investigation: ["probe_service", "fetch_logs", "check_policy", "fetch_user_data"], | |
| response: ["apply_fix", "notify_stakeholders", "rollback_fix", "respond"], | |
| resolution: ["verify_fix", "resolve", "write_postmortem", "update_kb"], | |
| }; | |
| suggestions = byPhase[obs.incident_phase] || byPhase.triage; | |
| } else if (obs) { | |
| const byTicketPhase = { | |
| unclassified: ["classify"], | |
| classified: ["route", "escalate"], | |
| routed: ["respond", "request_info", "resolve"], | |
| responding: ["respond", "request_info", "resolve"], | |
| escalated: ["resolve"], | |
| }; | |
| suggestions = byTicketPhase[obs.phase] || fallback; | |
| } | |
| const available = obs && Array.isArray(obs.available_actions) | |
| ? new Set(obs.available_actions) | |
| : null; | |
| suggestions.forEach((action) => { | |
| const btn = document.createElement("button"); | |
| btn.type = "button"; | |
| btn.className = "preset-btn"; | |
| btn.textContent = action; | |
| btn.disabled = available ? !available.has(action) : false; | |
| btn.addEventListener("click", () => { | |
| const selector = $("actionType"); | |
| selector.value = action; | |
| showFieldGroups(); | |
| }); | |
| presetRow.appendChild(btn); | |
| }); | |
| } | |
| function renderTicket(obs, info) { | |
| const body = $("ticketBody"); | |
| const pills = $("ticketPills"); | |
| pills.innerHTML = ""; | |
| if (!obs) { | |
| body.textContent = "No observation loaded."; | |
| body.classList.add("empty"); | |
| return; | |
| } | |
| body.classList.remove("empty"); | |
| body.textContent = obs.ticket_text != null ? obs.ticket_text : ""; | |
| function addPill(label, value, variant) { | |
| if (value === undefined || value === null || value === "") return; | |
| const p = document.createElement("div"); | |
| p.className = "pill" + (variant ? " " + variant : ""); | |
| const k = document.createElement("kbd"); | |
| k.textContent = label + ":"; | |
| const v = document.createElement("span"); | |
| v.className = "val"; | |
| v.textContent = String(value); | |
| p.appendChild(k); | |
| p.appendChild(v); | |
| pills.appendChild(p); | |
| } | |
| if (obs.mode) addPill("Mode", obs.mode, "mode-" + obs.mode); | |
| if (info && info.difficulty) addPill("Difficulty", String(info.difficulty)); | |
| const impact = (obs.customer_value || "").toString().toUpperCase(); | |
| addPill("Impact", impact); | |
| addPill("ID", obs.ticket_id); | |
| addPill("Tier", obs.customer_tier); | |
| addPill("Sentiment", obs.customer_sentiment); | |
| if (obs.mode === "incident" && obs.incident_phase) { | |
| addPill("Incident Phase", obs.incident_phase, phaseClass(obs.incident_phase)); | |
| } else { | |
| addPill("Phase", obs.phase, phaseClass(obs.phase)); | |
| } | |
| if (typeof obs.sla_steps_remaining === "number") { | |
| addPill("SLA steps left", String(obs.sla_steps_remaining)); | |
| } | |
| if (obs.mode === "incident" && obs.system_status) { | |
| let degraded = 0; | |
| let down = 0; | |
| let flickering = 0; | |
| Object.values(obs.system_status).forEach((state) => { | |
| if (state === "degraded") degraded += 1; | |
| if (state === "down") down += 1; | |
| if (state === "flickering") flickering += 1; | |
| }); | |
| addPill("Service Alerts", "D:" + degraded + " F:" + flickering + " X:" + down); | |
| } | |
| if (obs.category_hint) addPill("Hint", obs.category_hint); | |
| } | |
| function renderTimeline(obs) { | |
| const list = $("timelineList"); | |
| list.innerHTML = ""; | |
| if (!obs || !Array.isArray(obs.history) || obs.history.length === 0) { | |
| const empty = document.createElement("div"); | |
| empty.className = "timeline-empty"; | |
| empty.innerHTML = "No actions yet. Run <strong>Reset</strong>, then <strong>Execute step</strong>."; | |
| list.appendChild(empty); | |
| return; | |
| } | |
| obs.history.forEach(function (h) { | |
| const item = document.createElement("div"); | |
| item.className = "timeline-item"; | |
| const row = document.createElement("div"); | |
| row.className = "timeline-row"; | |
| const main = document.createElement("div"); | |
| main.className = "tl-main"; | |
| const sn = document.createElement("span"); | |
| sn.className = "tl-step-num"; | |
| sn.textContent = "Step " + h.step + ": "; | |
| const an = document.createElement("span"); | |
| an.className = "tl-action-name"; | |
| const actionType = extractActionType(h.action_taken); | |
| an.textContent = actionType || actionSummary(h.action_taken); | |
| main.appendChild(sn); | |
| main.appendChild(an); | |
| const chip = document.createElement("span"); | |
| chip.className = "tl-action-chip family-" + actionFamily(actionType); | |
| chip.textContent = actionFamily(actionType); | |
| main.appendChild(chip); | |
| const rw = document.createElement("div"); | |
| rw.className = "tl-reward"; | |
| const r = Number(h.reward_earned); | |
| rw.textContent = (r >= 0 ? "+" : "") + r.toFixed(2); | |
| if (r < 0) rw.classList.add("neg"); | |
| else if (r > 0) rw.classList.add("pos"); | |
| row.appendChild(main); | |
| row.appendChild(rw); | |
| item.appendChild(row); | |
| if (h.env_feedback) { | |
| const fb = document.createElement("div"); | |
| fb.className = "tl-feedback"; | |
| fb.textContent = h.env_feedback; | |
| item.appendChild(fb); | |
| } | |
| list.appendChild(item); | |
| }); | |
| } | |
| function syncActionAvailability(obs) { | |
| const select = $("actionType"); | |
| if (!select) return; | |
| const available = obs && Array.isArray(obs.available_actions) | |
| ? new Set(obs.available_actions) | |
| : null; | |
| Array.from(select.options).forEach((opt) => { | |
| if (!opt.value) return; | |
| opt.disabled = available ? !available.has(opt.value) : false; | |
| }); | |
| if (available && !available.has(select.value)) { | |
| const fallback = Array.from(select.options).find((opt) => opt.value && !opt.disabled); | |
| if (fallback) { | |
| select.value = fallback.value; | |
| } | |
| } | |
| showFieldGroups(); | |
| } | |
| function syncDifficultyByMode() { | |
| const mode = $("resetMode").value; | |
| const difficulty = $("resetDifficulty"); | |
| const drillMode = $("resetDrillMode"); | |
| const drillSeed = $("resetDrillSeed"); | |
| const nightmare = difficulty.querySelector('option[value="nightmare"]'); | |
| if (nightmare) nightmare.disabled = mode !== "incident"; | |
| if (mode !== "incident" && difficulty.value === "nightmare") { | |
| difficulty.value = ""; | |
| } | |
| if (drillMode) { | |
| drillMode.disabled = mode !== "incident"; | |
| if (mode !== "incident") { | |
| drillMode.value = "false"; | |
| } | |
| } | |
| if (drillSeed) { | |
| drillSeed.disabled = mode !== "incident"; | |
| if (mode !== "incident") { | |
| drillSeed.value = ""; | |
| } | |
| } | |
| } | |
| function showPayload(data, opts) { | |
| const obs = data.observation; | |
| outObs.textContent = obs == null ? "null" : pretty(obs); | |
| outReward.textContent = String(data.reward); | |
| outDone.textContent = String(data.done); | |
| outInfo.textContent = pretty(data.info != null ? data.info : {}); | |
| if (data.info && typeof data.info.session_id === "string") { | |
| sessionId = data.info.session_id; | |
| } | |
| renderTicket(obs, data.info); | |
| renderTimeline(obs); | |
| syncActionAvailability(obs); | |
| renderPresets(obs); | |
| mLast.textContent = Number(data.reward).toFixed(2); | |
| if (opts && opts.addStepReward && typeof data.reward === "number") { | |
| totalReward += data.reward; | |
| } | |
| mTotal.textContent = totalReward.toFixed(2); | |
| const ns = data.info && typeof data.info.normalized_score === "number" | |
| ? data.info.normalized_score | |
| : null; | |
| mScore.textContent = ns == null ? "β" : Math.round(ns * 100) + "%"; | |
| mStat.textContent = data.done ? "DONE" : "RUNNING"; | |
| } | |
| function showErrorToast(msg) { | |
| if (!msg || !toastRoot) return; | |
| const el = document.createElement("div"); | |
| el.className = "toast"; | |
| el.textContent = msg; | |
| el.setAttribute("role", "alert"); | |
| el.tabIndex = 0; | |
| el.title = "Click to dismiss"; | |
| toastRoot.appendChild(el); | |
| requestAnimationFrame(function () { | |
| el.classList.add("toast-visible"); | |
| }); | |
| function dismiss() { | |
| el.classList.remove("toast-visible"); | |
| setTimeout(function () { | |
| el.remove(); | |
| }, 280); | |
| } | |
| const t = window.setTimeout(dismiss, 8500); | |
| el.addEventListener("click", function () { | |
| window.clearTimeout(t); | |
| dismiss(); | |
| }); | |
| } | |
| function setStatus(msg, isErr) { | |
| status.textContent = msg || ""; | |
| status.className = isErr ? "err" : ""; | |
| if (isErr && msg) showErrorToast(msg); | |
| } | |
| async function parseJsonResponse(res) { | |
| const text = await res.text(); | |
| try { | |
| return { ok: res.ok, data: JSON.parse(text), raw: text }; | |
| } catch { | |
| return { ok: res.ok, data: null, raw: text }; | |
| } | |
| } | |
| async function doReset() { | |
| setStatus("POST /reset β¦"); | |
| const seedRaw = parseInt($("resetSeed").value, 10); | |
| const seed = Number.isFinite(seedRaw) ? seedRaw : 0; | |
| const diff = $("resetDifficulty").value; | |
| const drillEnabled = $("resetDrillMode").value === "true"; | |
| const drillSeedRaw = $("resetDrillSeed").value.trim(); | |
| const drillSeed = drillSeedRaw === "" ? null : parseInt(drillSeedRaw, 10); | |
| if (!sessionId) { | |
| sessionId = "ui-" + Math.random().toString(36).slice(2, 10); | |
| } | |
| const payload = { seed: seed, mode: $("resetMode").value, session_id: sessionId }; | |
| if (diff) payload.difficulty = diff; | |
| if (payload.mode === "incident" && drillEnabled) { | |
| payload.drill_mode = true; | |
| if (Number.isFinite(drillSeed)) payload.drill_seed = drillSeed; | |
| } | |
| const res = await fetch("/reset", { | |
| method: "POST", | |
| headers: requestHeaders(true), | |
| body: JSON.stringify(payload), | |
| }); | |
| const { ok, data, raw } = await parseJsonResponse(res); | |
| if (!ok || !data) { | |
| setStatus((data && data.detail) || raw || res.statusText, true); | |
| return; | |
| } | |
| totalReward = 0; | |
| episodeActive = true; | |
| syncStepButton(); | |
| showPayload(data, {}); | |
| mLast.textContent = "0.00"; | |
| mTotal.textContent = "0.00"; | |
| setStatus("POST /reset β " + res.status); | |
| } | |
| async function doState() { | |
| setStatus("GET /state β¦"); | |
| const res = await fetch("/state", { method: "GET", headers: requestHeaders(false) }); | |
| const { ok, data, raw } = await parseJsonResponse(res); | |
| if (!ok || !data) { | |
| setStatus((data && data.detail) || raw || res.statusText, true); | |
| return; | |
| } | |
| if (data.observation != null) { | |
| episodeActive = !data.done; | |
| } | |
| syncStepButton(); | |
| showPayload(data, {}); | |
| setStatus("GET /state β " + res.status); | |
| } | |
| async function doStep() { | |
| if (!episodeActive) { | |
| setStatus("Call Reset before executing a step.", true); | |
| return; | |
| } | |
| const action = buildAction(); | |
| syncJsonPreview(); | |
| setStatus("POST /step β¦"); | |
| const res = await fetch("/step", { | |
| method: "POST", | |
| headers: requestHeaders(true), | |
| body: JSON.stringify({ action: action, session_id: sessionId }), | |
| }); | |
| const { ok, data, raw } = await parseJsonResponse(res); | |
| if (!ok || !data) { | |
| const detail = data && (data.detail || data.message); | |
| setStatus(String(detail || raw || res.statusText), true); | |
| return; | |
| } | |
| if (data.done) { | |
| episodeActive = false; | |
| syncStepButton(); | |
| } | |
| showPayload(data, { addStepReward: true }); | |
| setStatus("POST /step β " + res.status); | |
| } | |
| $("btnReset").addEventListener("click", () => { doReset().catch((e) => setStatus(String(e), true)); }); | |
| $("btnState").addEventListener("click", () => { doState().catch((e) => setStatus(String(e), true)); }); | |
| $("btnStep").addEventListener("click", () => { doStep().catch((e) => setStatus(String(e), true)); }); | |
| $("resetMode").addEventListener("change", () => { | |
| syncDifficultyByMode(); | |
| syncJsonPreview(); | |
| }); | |
| syncDifficultyByMode(); | |
| showFieldGroups(); | |
| syncStepButton(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| # ββ routes βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def ui() -> HTMLResponse: | |
| """Browser debug UI (HTML). Use **GET /** in the browser; API clients should use JSON endpoints below.""" | |
| return HTMLResponse(content=_DEBUG_UI_HTML) | |
| async def health() -> dict[str, str]: | |
| """Return `{\"status\": \"ok\"}` when the server is up.""" | |
| return {"status": "ok"} | |
| async def reset( | |
| req: ResetRequest | None = None, | |
| x_session_id: str | None = Header(default=None, alias="X-Session-ID"), | |
| ) -> dict[str, Any]: | |
| """Start a new episode with a deterministically selected ticket. Body is optional (`seed=0`, all difficulties).""" | |
| if req is None: | |
| req = ResetRequest() | |
| session_id = _resolve_session_id(req.session_id, x_session_id) | |
| env = await _get_env(session_id, create_if_missing=True) | |
| try: | |
| if isinstance(env, SandboxEnv): | |
| result = await env.reset( | |
| seed=req.seed, | |
| difficulty=req.difficulty, | |
| mode=req.mode, | |
| drill_mode=req.drill_mode, | |
| drill_seed=req.drill_seed, | |
| ) | |
| else: | |
| result = await env.reset(seed=req.seed, difficulty=req.difficulty, mode=req.mode) | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail=str(exc)) from exc | |
| return _result_to_dict(result, session_id=session_id) | |
| async def step( | |
| req: StepRequest, | |
| x_session_id: str | None = Header(default=None, alias="X-Session-ID"), | |
| ) -> dict[str, Any]: | |
| """Apply one agent action. Invalid **phase** actions still return HTTP 200 with a penalty in `reward`.""" | |
| session_id = _resolve_session_id(req.session_id, x_session_id) | |
| env = await _get_env(session_id, create_if_missing=True) | |
| try: | |
| result = await env.step(req.action) | |
| except Exception as exc: | |
| raise HTTPException(status_code=400, detail=str(exc)) from exc | |
| return _result_to_dict(result, session_id=session_id) | |
| async def state( | |
| session_id: str | None = None, | |
| x_session_id: str | None = Header(default=None, alias="X-Session-ID"), | |
| ) -> dict[str, Any]: | |
| """Read the current observation without advancing time. If `reset` was never called, `observation` is `null`.""" | |
| resolved_session_id = _resolve_session_id(session_id, x_session_id) | |
| env = await _get_env(resolved_session_id, create_if_missing=True) | |
| result = await env.state() | |
| if result is None: | |
| return { | |
| "observation": None, | |
| "reward": 0.0, | |
| "done": False, | |
| "info": {"session_id": resolved_session_id}, | |
| } | |
| return _result_to_dict(result, session_id=resolved_session_id) | |
| async def close( | |
| session_id: str | None = None, | |
| x_session_id: str | None = Header(default=None, alias="X-Session-ID"), | |
| ) -> dict[str, str]: | |
| """Release the current episode; call **reset** before stepping again.""" | |
| resolved_session_id = _resolve_session_id(session_id, x_session_id) | |
| await _close_session(resolved_session_id) | |
| return {"status": "closed", "session_id": resolved_session_id} | |
| async def inference_endpoint() -> dict[str, Any]: | |
| """Execute the bundled LLM inference loop once. Requires server-side env (e.g. `HF_TOKEN`); see project README.""" | |
| import inference | |
| buf = io.StringIO() | |
| with redirect_stdout(buf): | |
| await inference.run() | |
| stdout = buf.getvalue() | |
| score = 0.0 | |
| success = False | |
| for line in stdout.strip().splitlines(): | |
| if line.startswith("[END]"): | |
| for part in line.split(): | |
| if part.startswith("score="): | |
| score = float(part.split("=")[1]) | |
| if part.startswith("success="): | |
| success = part.split("=")[1] == "true" | |
| return {"stdout": stdout, "score": score, "success": success} | |
| # ββ entrypoint βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def main() -> None: | |
| import uvicorn | |
| workers = int(os.environ.get("WEB_CONCURRENCY", "1")) | |
| uvicorn.run("server.app:app", host="0.0.0.0", port=7860, workers=max(1, workers)) | |
| if __name__ == "__main__": | |
| main() | |