fix(compare): stop blind mode leaking model identities via session names (#1318)
Blind Compare anonymized the pane headers, but each pane still created a helper chat session named "[CMP] <real-model>" and GET /api/sessions returned the session's model field. So the sidebar and the session-list API let a user map "Model A" back to its real model before voting, defeating the blind test.
- Frontend (static/js/compare/index.js, panes.js): in blind mode, name helper sessions by their neutral slot ("[CMP] Model A") instead of the model, matching the existing blind pane labels.
- Backend GET /api/sessions (routes/session_routes.py): blank the model field for [CMP]-prefixed helper sessions via a new _public_model helper.
- Backend /api/compare/start (routes/compare_routes.py): name blind sessions by slot and withhold model_left/model_right/mapping from the blind response (revealed at /vote).
- Tests: tests/test_blind_compare_redaction.py.
Fixes #1285.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -66,13 +66,33 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
comp_id = str(uuid.uuid4())
|
||||
sid_a = str(uuid.uuid4())
|
||||
sid_b = str(uuid.uuid4())
|
||||
user = getattr(request.state, 'current_user', None)
|
||||
|
||||
# Blind mapping: randomly assign left/right
|
||||
blind = str(is_blind).lower() == "true"
|
||||
if blind:
|
||||
mapping = {"left": "a", "right": "b"}
|
||||
if random.random() > 0.5:
|
||||
mapping = {"left": "b", "right": "a"}
|
||||
else:
|
||||
mapping = {"left": "a", "right": "b"}
|
||||
|
||||
# Map session IDs to left/right based on blind mapping
|
||||
session_left = sid_a if mapping["left"] == "a" else sid_b
|
||||
session_right = sid_a if mapping["right"] == "a" else sid_b
|
||||
|
||||
# In blind mode, name the helper sessions by their neutral slot
|
||||
# ("Model A" / "Model B") instead of the real model. Otherwise the
|
||||
# session name leaks the model in the sidebar and GET /api/sessions,
|
||||
# de-anonymizing the comparison before the user votes (issue #1285).
|
||||
slot_name = {session_left: "Model A", session_right: "Model B"}
|
||||
|
||||
# Create ephemeral sessions (prefixed [CMP])
|
||||
for sid, model, endpoint in [(sid_a, model_a, endpoint_a), (sid_b, model_b, endpoint_b)]:
|
||||
user = getattr(request.state, 'current_user', None)
|
||||
name = f"[CMP] {slot_name[sid]}" if blind else f"[CMP] {model.split('/')[-1]}"
|
||||
session_manager.create_session(
|
||||
session_id=sid,
|
||||
name=f"[CMP] {model.split('/')[-1]}",
|
||||
name=name,
|
||||
endpoint_url=endpoint,
|
||||
model=model,
|
||||
rag=False,
|
||||
@@ -93,15 +113,6 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Blind mapping: randomly assign left/right
|
||||
blind = str(is_blind).lower() == "true"
|
||||
if blind:
|
||||
mapping = {"left": "a", "right": "b"}
|
||||
if random.random() > 0.5:
|
||||
mapping = {"left": "b", "right": "a"}
|
||||
else:
|
||||
mapping = {"left": "a", "right": "b"}
|
||||
|
||||
# Store comparison record
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -121,18 +132,18 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Map session IDs to left/right based on blind mapping
|
||||
session_left = sid_a if mapping["left"] == "a" else sid_b
|
||||
session_right = sid_a if mapping["right"] == "a" else sid_b
|
||||
|
||||
# In blind mode, withhold the model identities AND the left/right
|
||||
# mapping from the response. The client already knows model_a/model_b
|
||||
# (it sent them), so returning either would defeat blind mode. They are
|
||||
# revealed by POST /api/compare/{id}/vote once the user has voted (#1285).
|
||||
return {
|
||||
"id": comp_id,
|
||||
"session_left": session_left,
|
||||
"session_right": session_right,
|
||||
"model_left": model_a if mapping["left"] == "a" else model_b,
|
||||
"model_right": model_a if mapping["right"] == "a" else model_b,
|
||||
"model_left": None if blind else (model_a if mapping["left"] == "a" else model_b),
|
||||
"model_right": None if blind else (model_a if mapping["right"] == "a" else model_b),
|
||||
"is_blind": blind,
|
||||
"mapping": mapping,
|
||||
"mapping": None if blind else mapping,
|
||||
}
|
||||
|
||||
@router.post("/{comp_id}/vote")
|
||||
|
||||
@@ -21,6 +21,22 @@ def _sanitize_export_filename(name: str) -> str:
|
||||
return name[:128]
|
||||
|
||||
|
||||
# Blind-compare helper sessions are created with this name prefix. Their real
|
||||
# model must never surface in the session list / sidebar — otherwise a blind
|
||||
# comparison can be de-anonymized before the user votes (issue #1285).
|
||||
COMPARE_SESSION_PREFIX = "[CMP] "
|
||||
|
||||
|
||||
def _public_model(name: str, model: str) -> str:
|
||||
"""Blank out the real model of blind-compare helper sessions so the
|
||||
session list can't be used to map a neutral pane label ("Model A") back
|
||||
to its model. The Compare UI tracks models client-side, so hiding it here
|
||||
costs the sidebar nothing. See issue #1285."""
|
||||
if (name or "").startswith(COMPARE_SESSION_PREFIX):
|
||||
return ""
|
||||
return model
|
||||
|
||||
|
||||
def _verify_session_owner(request: Request, session_id: str, session_manager=None):
|
||||
"""Verify the current user owns the session. Raises 404 if not.
|
||||
|
||||
@@ -215,7 +231,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
sessions = [{"id": s.id, "name": s.name, "model": s.model,
|
||||
sessions = [{"id": s.id, "name": s.name, "model": _public_model(s.name, s.model),
|
||||
"endpoint_url": s.endpoint_url, "rag": s.rag,
|
||||
"archived": s.archived, "folder": folder_map.get(s.id),
|
||||
"total_tokens": token_map.get(s.id, 0),
|
||||
|
||||
Reference in New Issue
Block a user