Spaces:
Sleeping
Sleeping
Commit ·
d42e3af
1
Parent(s): b29ecf8
feat(web): show handover memory + persona rubric for LLM spectate (same as human play); highlight each entity's behaviour as a courier passes it
Browse files
proteus/game/scenarios/errand_world.py
CHANGED
|
@@ -73,6 +73,53 @@ PERSONA_LABELS: dict[str, str] = {
|
|
| 73 |
"civic": "모범 시민형", "warm_outlaw": "따뜻한 무법자형", "opportunist": "한탕주의자형",
|
| 74 |
}
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
@dataclass(frozen=True)
|
| 78 |
class WorldLayout:
|
|
|
|
| 73 |
"civic": "모범 시민형", "warm_outlaw": "따뜻한 무법자형", "opportunist": "한탕주의자형",
|
| 74 |
}
|
| 75 |
|
| 76 |
+
# Rubric display: per-entity Korean name + the Korean phrasing of each reaction.
|
| 77 |
+
# Used by the web UI to show, beside the handover memory, exactly how the chosen
|
| 78 |
+
# persona behaves at each entity (and to highlight the row as a figure passes it).
|
| 79 |
+
RUBRIC_LABELS: dict[str, tuple[str, dict[str, str]]] = {
|
| 80 |
+
"light": ("횡단보도", {"wait": "빨간불엔 대기", "cross": "무단 횡단"}),
|
| 81 |
+
"construction": ("공사장", {"detour": "우회", "pass": "통과(밟고 감)"}),
|
| 82 |
+
"wallet": ("지갑", {"ignore": "무시", "grab": "주워 감"}),
|
| 83 |
+
"pedestrian": ("행인", {"help": "도와줌", "ignore": "무시"}),
|
| 84 |
+
"grass": ("잔디", {"avoid": "피해 감(도로로)", "cut": "가로질러 감"}),
|
| 85 |
+
}
|
| 86 |
+
_RUBRIC_ORDER = ("light", "construction", "wallet", "pedestrian", "grass")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def persona_rubric(persona_id: str, lay: "WorldLayout | None" = None) -> list[dict]:
|
| 90 |
+
"""The chosen persona's behaviour at each entity, with display labels and the
|
| 91 |
+
entity's bounding box on *lay* (default GAME_LAYOUT) so the UI can highlight
|
| 92 |
+
the row when a courier passes that entity.
|
| 93 |
+
|
| 94 |
+
Each row: ``{key, entity, reaction, reaction_label, rect:[x0,y0,x1,y1]}``.
|
| 95 |
+
"""
|
| 96 |
+
layout = lay if lay is not None else GAME_LAYOUT
|
| 97 |
+
policy = PERSONAS[persona_id]
|
| 98 |
+
|
| 99 |
+
def _bbox(key: str) -> tuple[int, int, int, int]:
|
| 100 |
+
if key == "light":
|
| 101 |
+
return layout.crosswalk
|
| 102 |
+
if key == "construction":
|
| 103 |
+
return layout.construction
|
| 104 |
+
if key == "grass":
|
| 105 |
+
return layout.grass
|
| 106 |
+
if key == "wallet":
|
| 107 |
+
wx, wy = layout.wallet
|
| 108 |
+
return (wx, wy, wx, wy)
|
| 109 |
+
px, py = layout.pedestrian
|
| 110 |
+
return (px, py, px + AGENT - 1, py + AGENT - 1)
|
| 111 |
+
|
| 112 |
+
rows = []
|
| 113 |
+
for key in _RUBRIC_ORDER:
|
| 114 |
+
entity, reaction_labels = RUBRIC_LABELS[key]
|
| 115 |
+
reaction = policy[key]
|
| 116 |
+
rows.append({
|
| 117 |
+
"key": key, "entity": entity, "reaction": reaction,
|
| 118 |
+
"reaction_label": reaction_labels.get(reaction, reaction),
|
| 119 |
+
"rect": list(_bbox(key)),
|
| 120 |
+
})
|
| 121 |
+
return rows
|
| 122 |
+
|
| 123 |
|
| 124 |
@dataclass(frozen=True)
|
| 125 |
class WorldLayout:
|
proteus/web/local/server.py
CHANGED
|
@@ -136,6 +136,7 @@ def _memory_info(session) -> dict:
|
|
| 136 |
"frames": [],
|
| 137 |
"variants": [],
|
| 138 |
"selected": None,
|
|
|
|
| 139 |
}
|
| 140 |
if mem is not None:
|
| 141 |
from proteus.game.scenarios.base import get_scenario
|
|
@@ -156,6 +157,12 @@ def _memory_info(session) -> dict:
|
|
| 156 |
for pid, ck in variants.items()
|
| 157 |
]
|
| 158 |
info["selected"] = mem.persona_weight_id or next(iter(variants))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
return info
|
| 160 |
|
| 161 |
|
|
|
|
| 136 |
"frames": [],
|
| 137 |
"variants": [],
|
| 138 |
"selected": None,
|
| 139 |
+
"rubric": None,
|
| 140 |
}
|
| 141 |
if mem is not None:
|
| 142 |
from proteus.game.scenarios.base import get_scenario
|
|
|
|
| 157 |
for pid, ck in variants.items()
|
| 158 |
]
|
| 159 |
info["selected"] = mem.persona_weight_id or next(iter(variants))
|
| 160 |
+
sel = info["selected"]
|
| 161 |
+
info["rubric"] = {
|
| 162 |
+
"persona": sel,
|
| 163 |
+
"label": w.PERSONA_LABELS.get(sel, sel),
|
| 164 |
+
"rows": w.persona_rubric(sel), # per-entity reaction + live coords
|
| 165 |
+
}
|
| 166 |
return info
|
| 167 |
|
| 168 |
|
proteus/web/local/static/index.html
CHANGED
|
@@ -43,6 +43,12 @@
|
|
| 43 |
.metric{flex:1;background:#0f1218;border:1px solid var(--line);border-radius:8px;padding:10px;text-align:center}
|
| 44 |
.metric b{display:block;font-size:20px;color:var(--fg)} .metric span{font-size:12px;color:var(--muted)}
|
| 45 |
td.approx{color:var(--muted)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
</style>
|
| 47 |
</head>
|
| 48 |
<body>
|
|
@@ -87,6 +93,7 @@
|
|
| 87 |
<div id="play" class="hidden">
|
| 88 |
<div class="status"><span id="meta"></span><span id="turnInfo"></span><span id="hp" style="margin-left:12px"></span></div>
|
| 89 |
<div id="memInfo" style="color:var(--muted);font-size:12px"></div>
|
|
|
|
| 90 |
<div id="memReplay" class="hidden" style="padding:6px 0">
|
| 91 |
<div style="margin-bottom:6px">
|
| 92 |
<span style="color:var(--fg)">memory replay</span>
|
|
@@ -199,6 +206,7 @@ function showState(st) {
|
|
| 199 |
$("memReplay").classList.add("hidden");
|
| 200 |
}
|
| 201 |
drawGrid(st.grid);
|
|
|
|
| 202 |
if (st.outcome) { PLAYING = false; showEnd(st); }
|
| 203 |
}
|
| 204 |
|
|
@@ -331,7 +339,34 @@ async function memPlay() {
|
|
| 331 |
MEM_PLAY = false; $("memReplayPlay").textContent = "▶ play";
|
| 332 |
}
|
| 333 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
function showMem(m) {
|
|
|
|
| 335 |
if (!m || !m.attached) {
|
| 336 |
$("memInfo").textContent = m ? "memory: none (no pre-roll before the task)" : "";
|
| 337 |
MEM_FRAMES = []; MEM_PLAY = false; $("memReplay").classList.add("hidden");
|
|
@@ -384,6 +419,7 @@ function showSpectateState(st) {
|
|
| 384 |
$("turnInfo").textContent = (st.play_turns == null)
|
| 385 |
? `Turn ${st.turn_idx}` : `Turn ${st.turn_idx} / ${st.play_turns}`;
|
| 386 |
drawGrid(st.grid);
|
|
|
|
| 387 |
const last = st.turns_so_far[st.turns_so_far.length - 1];
|
| 388 |
if (last) { $("reasoning").textContent = last.reasoning || ""; appendTurn(last); }
|
| 389 |
}
|
|
@@ -420,8 +456,8 @@ async function startSpectate(body) {
|
|
| 420 |
$("start").classList.add("hidden"); $("end").classList.add("hidden");
|
| 421 |
$("play").classList.remove("hidden"); $("analysis").classList.remove("hidden"); $("pad")?.classList?.add?.("hidden");
|
| 422 |
$("meta").textContent = `${body.scenario} · ${body.difficulty} · ${body.model}`;
|
| 423 |
-
showMem(data.memory);
|
| 424 |
-
$("
|
| 425 |
const st = data.state;
|
| 426 |
if (st.cut_frames) await animateCut(st.cut_frames);
|
| 427 |
drawGrid(st.grid);
|
|
|
|
| 43 |
.metric{flex:1;background:#0f1218;border:1px solid var(--line);border-radius:8px;padding:10px;text-align:center}
|
| 44 |
.metric b{display:block;font-size:20px;color:var(--fg)} .metric span{font-size:12px;color:var(--muted)}
|
| 45 |
td.approx{color:var(--muted)}
|
| 46 |
+
#rubric{margin:6px 0;font-size:13px}
|
| 47 |
+
#rubric .rubhead{color:var(--fg);margin-bottom:4px}
|
| 48 |
+
#rubric table{width:100%;border-collapse:collapse}
|
| 49 |
+
#rubric th{color:var(--muted);font-weight:normal;border-bottom:1px solid var(--line);padding:3px 6px}
|
| 50 |
+
#rubric td{text-align:center;padding:4px 6px;border-bottom:1px solid var(--line);transition:background .15s}
|
| 51 |
+
#rubric td.rub-active{background:#3b5bdb;color:#fff;border-radius:4px;font-weight:bold}
|
| 52 |
</style>
|
| 53 |
</head>
|
| 54 |
<body>
|
|
|
|
| 93 |
<div id="play" class="hidden">
|
| 94 |
<div class="status"><span id="meta"></span><span id="turnInfo"></span><span id="hp" style="margin-left:12px"></span></div>
|
| 95 |
<div id="memInfo" style="color:var(--muted);font-size:12px"></div>
|
| 96 |
+
<div id="rubric" class="hidden"></div>
|
| 97 |
<div id="memReplay" class="hidden" style="padding:6px 0">
|
| 98 |
<div style="margin-bottom:6px">
|
| 99 |
<span style="color:var(--fg)">memory replay</span>
|
|
|
|
| 206 |
$("memReplay").classList.add("hidden");
|
| 207 |
}
|
| 208 |
drawGrid(st.grid);
|
| 209 |
+
highlightRubric(st.grid); // light up the entity rows a courier is passing
|
| 210 |
if (st.outcome) { PLAYING = false; showEnd(st); }
|
| 211 |
}
|
| 212 |
|
|
|
|
| 339 |
MEM_PLAY = false; $("memReplayPlay").textContent = "▶ play";
|
| 340 |
}
|
| 341 |
|
| 342 |
+
// --- persona rubric: which memory the player got + how it acts at each entity ---
|
| 343 |
+
const COURIER_IDX = 9; // palette index of a courier cell on the grid
|
| 344 |
+
let RUBRIC = null;
|
| 345 |
+
function renderRubric(m) {
|
| 346 |
+
const el = $("rubric");
|
| 347 |
+
RUBRIC = (m && m.rubric) || null;
|
| 348 |
+
if (!RUBRIC) { el.classList.add("hidden"); el.innerHTML = ""; return; }
|
| 349 |
+
el.classList.remove("hidden");
|
| 350 |
+
el.innerHTML =
|
| 351 |
+
`<div class="rubhead">제공된 기억: <b>${RUBRIC.label}</b> — 각 지점에서의 행동 (배달원이 지날 때 강조)</div>`
|
| 352 |
+
+ `<table><tr>${RUBRIC.rows.map(r => `<th>${r.entity}</th>`).join("")}</tr>`
|
| 353 |
+
+ `<tr>${RUBRIC.rows.map((r, i) => `<td id="rub${i}">${r.reaction_label}</td>`).join("")}</tr></table>`;
|
| 354 |
+
}
|
| 355 |
+
function highlightRubric(grid) {
|
| 356 |
+
if (!RUBRIC || !grid) return;
|
| 357 |
+
RUBRIC.rows.forEach((r, i) => {
|
| 358 |
+
const [x0, y0, x1, y1] = r.rect;
|
| 359 |
+
let near = false;
|
| 360 |
+
for (let y = Math.max(0, y0 - 1); y <= Math.min(grid.length - 1, y1 + 1) && !near; y++)
|
| 361 |
+
for (let x = Math.max(0, x0 - 1); x <= Math.min((grid[0] || []).length - 1, x1 + 1); x++)
|
| 362 |
+
if (grid[y][x] === COURIER_IDX) { near = true; break; } // a courier is on/at this entity
|
| 363 |
+
const td = $("rub" + i);
|
| 364 |
+
if (td) td.classList.toggle("rub-active", near);
|
| 365 |
+
});
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
function showMem(m) {
|
| 369 |
+
renderRubric(m);
|
| 370 |
if (!m || !m.attached) {
|
| 371 |
$("memInfo").textContent = m ? "memory: none (no pre-roll before the task)" : "";
|
| 372 |
MEM_FRAMES = []; MEM_PLAY = false; $("memReplay").classList.add("hidden");
|
|
|
|
| 419 |
$("turnInfo").textContent = (st.play_turns == null)
|
| 420 |
? `Turn ${st.turn_idx}` : `Turn ${st.turn_idx} / ${st.play_turns}`;
|
| 421 |
drawGrid(st.grid);
|
| 422 |
+
highlightRubric(st.grid); // light up the entity rows a courier is passing
|
| 423 |
const last = st.turns_so_far[st.turns_so_far.length - 1];
|
| 424 |
if (last) { $("reasoning").textContent = last.reasoning || ""; appendTurn(last); }
|
| 425 |
}
|
|
|
|
| 456 |
$("start").classList.add("hidden"); $("end").classList.add("hidden");
|
| 457 |
$("play").classList.remove("hidden"); $("analysis").classList.remove("hidden"); $("pad")?.classList?.add?.("hidden");
|
| 458 |
$("meta").textContent = `${body.scenario} · ${body.difficulty} · ${body.model}`;
|
| 459 |
+
showMem(data.memory); // show the SAME handover memory (+ persona rubric) the LLM was given
|
| 460 |
+
$("memReplayDone").classList.add("hidden"); // "start playing" is human-only; the LLM auto-plays
|
| 461 |
const st = data.state;
|
| 462 |
if (st.cut_frames) await animateCut(st.cut_frames);
|
| 463 |
drawGrid(st.grid);
|
tests/web/test_errand_web.py
CHANGED
|
@@ -34,3 +34,16 @@ def test_interact_step_accepted():
|
|
| 34 |
sid = payload["session_id"]
|
| 35 |
status, _b, _c = handle_request("POST", f"/session/{sid}/act", {"action": "interact"}, reg)
|
| 36 |
assert status == 200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
sid = payload["session_id"]
|
| 35 |
status, _b, _c = handle_request("POST", f"/session/{sid}/act", {"action": "interact"}, reg)
|
| 36 |
assert status == 200
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_memory_exposes_persona_rubric():
|
| 40 |
+
reg = {}
|
| 41 |
+
_s, payload, _c = handle_request(
|
| 42 |
+
"POST", "/spectate",
|
| 43 |
+
{"scenario": "errand_runner", "seed": 7, "play_turns": 5, "model": "fake:demo"}, reg)
|
| 44 |
+
rub = payload["memory"]["rubric"]
|
| 45 |
+
assert rub is not None and rub["persona"] in {"civic", "warm_outlaw", "opportunist"}
|
| 46 |
+
keys = {row["key"] for row in rub["rows"]}
|
| 47 |
+
assert keys == {"light", "construction", "wallet", "pedestrian", "grass"}
|
| 48 |
+
for row in rub["rows"]:
|
| 49 |
+
assert row["entity"] and row["reaction_label"] and len(row["rect"]) == 4
|