irregular6612 commited on
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
- $("memReplay").classList.add("hidden"); // step-through replay is play-only; spectate goes live immediately
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