Skip to content

Commit 4d1bcc4

Browse files
committed
Improve session UX and first-round diversity
1 parent 9523d45 commit 4d1bcc4

8 files changed

Lines changed: 206 additions & 48 deletions

File tree

app/engine/orchestrator.py

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -176,11 +176,25 @@ def generate_round(
176176
self._report_progress(progress_callback, 52, "Rendering candidate images on the model backend")
177177
# Render each candidate independently so future versions can tolerate
178178
# partial round failures without changing the orchestration contract.
179-
for candidate in candidates:
179+
render_progress_start = 52
180+
render_progress_end = 74
181+
total_candidates = max(1, len(candidates))
182+
for index, candidate in enumerate(candidates, start=1):
183+
progress = render_progress_start + int((render_progress_end - render_progress_start) * ((index - 1) / total_candidates))
180184
candidate.round_id = round_obj.id
181185
if candidate.generation_params.get("carried_forward") and candidate.image_path:
186+
self._report_progress(
187+
progress_callback,
188+
progress,
189+
f"Using saved image {index} of {total_candidates} from the previous winning round",
190+
)
182191
candidate.render_status = RenderStatus.succeeded
183192
continue
193+
self._report_progress(
194+
progress_callback,
195+
progress,
196+
f"Generating image {index} of {total_candidates} on the model backend",
197+
)
184198
candidate = self.generator.render_candidate(session, candidate)
185199
candidate.render_status = RenderStatus.succeeded
186200
round_obj.candidates = candidates
@@ -447,27 +461,61 @@ def _widen_first_round_candidates(session: Session, proposed_candidates: list[Ca
447461
return proposed_candidates
448462

449463
boosted_candidates: list[Candidate] = []
450-
boost_radius = min(session.config.trust_radius * 1.35, 0.6)
451-
min_radius = min(max(session.config.trust_radius * 0.85, 0.18), boost_radius)
464+
dimensions = max(1, len(session.current_z))
465+
boost_radius = min(max(session.config.trust_radius * 1.55, 0.34), 0.72)
466+
min_radius = min(max(session.config.trust_radius * 0.95, 0.24), boost_radius)
452467
for index, candidate in enumerate(proposed_candidates):
453-
scale = 1.2 + (0.12 * index)
454-
boosted_z = clamp_vector([value * scale for value in candidate.z], boost_radius)
468+
spread_direction = Orchestrator._first_round_spread_direction(index, dimensions)
469+
scale = 1.15 + (0.1 * index)
470+
blended = [
471+
(original * 0.35) + (spread * boost_radius)
472+
for original, spread in zip(candidate.z, spread_direction, strict=False)
473+
]
474+
boosted_z = clamp_vector(blended, boost_radius)
455475
length = math.sqrt(sum(value * value for value in boosted_z))
456476
if 0.0 < length < min_radius:
457477
normalization = min_radius / length
458478
boosted_z = clamp_vector([value * normalization for value in boosted_z], boost_radius)
459479
length = math.sqrt(sum(value * value for value in boosted_z))
460480
if length == 0.0:
461-
axis = index % max(1, len(session.current_z))
481+
axis = index % dimensions
462482
boosted_z = [0.0 for _ in session.current_z]
463483
boosted_z[axis] = min_radius
464484
candidate.z = boosted_z
465485
candidate.generation_params["first_round_diversity_boost"] = True
466486
candidate.generation_params["first_round_diversity_scale"] = round(scale, 3)
467487
candidate.generation_params["first_round_min_radius"] = round(min_radius, 3)
488+
candidate.generation_params["first_round_spread_direction"] = [round(value, 4) for value in spread_direction]
468489
boosted_candidates.append(candidate)
469490
return boosted_candidates
470491

492+
@staticmethod
493+
def _first_round_spread_direction(index: int, dimensions: int) -> list[float]:
494+
"""Build a deliberately separated first-round direction for visible diversity."""
495+
496+
vector = [0.0 for _ in range(dimensions)]
497+
primary_axis = index % dimensions
498+
secondary_axis = (index + 1) % dimensions
499+
tertiary_axis = (index + 2) % dimensions
500+
primary_sign = 1.0 if index % 2 == 0 else -1.0
501+
secondary_sign = -1.0 if index % 3 == 1 else 1.0
502+
tertiary_sign = -1.0 if index % 4 >= 2 else 1.0
503+
504+
vector[primary_axis] = 1.0 * primary_sign
505+
if dimensions > 1:
506+
vector[secondary_axis] += 0.55 * secondary_sign
507+
if dimensions > 2:
508+
vector[tertiary_axis] += 0.3 * tertiary_sign
509+
if dimensions > 3:
510+
extra_axis = (index + 3) % dimensions
511+
vector[extra_axis] += 0.22 if index % 2 == 0 else -0.22
512+
513+
length = math.sqrt(sum(value * value for value in vector))
514+
if length == 0.0:
515+
vector[0] = 1.0
516+
return vector
517+
return [value / length for value in vector]
518+
471519
@staticmethod
472520
def _compose_round_candidates(
473521
*,

app/frontend/static/app.js

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,18 @@ function traceFrontend(event, details = {}) {
6868

6969
async function postJson(url, body) {
7070
traceFrontend("http.request.started", { url, body_keys: Object.keys(body || {}) });
71-
const response = await fetch(url, {
72-
method: "POST",
73-
headers: { "Content-Type": "application/json" },
74-
body: JSON.stringify(body),
75-
});
71+
let response;
72+
try {
73+
response = await fetch(url, {
74+
method: "POST",
75+
headers: { "Content-Type": "application/json" },
76+
body: JSON.stringify(body),
77+
});
78+
} catch (error) {
79+
const message = "Could not reach the server. Make sure the app is still running, then try again.";
80+
traceFrontend("http.request.failed", { url, detail: message, error_name: error?.name || "network_error" });
81+
throw new Error(message);
82+
}
7683
if (!response.ok) {
7784
const text = await response.text();
7885
let message = text || `Request failed: ${response.status}`;
@@ -118,6 +125,18 @@ async function pollJob(statusUrl, { onProgress } = {}) {
118125
}
119126
}
120127

128+
async function runNextRoundJob(sessionId, { queuedLabel, runningFallbackLabel } = {}) {
129+
const job = await postJson(`/sessions/${sessionId}/rounds/next/async`, {});
130+
setStatus(queuedLabel || "Queueing round generation...");
131+
setProgress(55, queuedLabel || "Queueing next round");
132+
await pollJob(job.status_url, {
133+
onProgress: (snapshot) => {
134+
setStatus(snapshot.status_message);
135+
setProgress(snapshot.progress, snapshot.status_message || runningFallbackLabel || "Generating next round");
136+
},
137+
});
138+
}
139+
121140
function collectRatings() {
122141
return Array.from(document.querySelectorAll(".rating-input")).map((input) => ({
123142
candidateId: input.dataset.candidateId,
@@ -308,12 +327,9 @@ if (nextRoundButton) {
308327
setStatus("Queueing round generation...");
309328
setProgress(5, "Queueing next round");
310329
try {
311-
const job = await postJson(`/sessions/${sessionId}/rounds/next/async`, {});
312-
await pollJob(job.status_url, {
313-
onProgress: (snapshot) => {
314-
setStatus(snapshot.status_message);
315-
setProgress(snapshot.progress, snapshot.status_message || "Generating next round");
316-
},
330+
await runNextRoundJob(sessionId, {
331+
queuedLabel: "Queueing round generation...",
332+
runningFallbackLabel: "Generating next round",
317333
});
318334
setStatus("Round generated. Refreshing session view...");
319335
setProgress(100, "Round completed");
@@ -342,6 +358,7 @@ if (submitFeedbackButton) {
342358
submitFeedbackButton.addEventListener("click", async () => {
343359
if (submitFeedbackButton.disabled) return;
344360
const feedbackMode = submitFeedbackButton.dataset.feedbackMode || "scalar_rating";
361+
const sessionId = submitFeedbackButton.dataset.sessionId;
345362
try {
346363
const request = buildFeedbackPayload(feedbackMode);
347364
traceFrontend("feedback.submit.clicked", {
@@ -361,8 +378,18 @@ if (submitFeedbackButton) {
361378
setProgress(snapshot.progress, snapshot.status_message || "Applying feedback");
362379
},
363380
});
364-
setStatus("Feedback submitted. Refreshing session view...");
365-
setProgress(100, "Feedback applied");
381+
traceFrontend("feedback.submit.completed", {
382+
round_id: submitFeedbackButton.dataset.roundId,
383+
session_id: sessionId,
384+
});
385+
setStatus("Feedback applied. Starting the next round...");
386+
setProgress(50, "Preparing next round");
387+
await runNextRoundJob(sessionId, {
388+
queuedLabel: "Queueing next round after feedback...",
389+
runningFallbackLabel: "Generating the next round",
390+
});
391+
setStatus("Next round ready. Refreshing session view...");
392+
setProgress(100, "Next round completed");
366393
window.location.reload();
367394
} catch (error) {
368395
setStatus(error.message, true);

app/frontend/templates/index.html

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ <h2>Resume sessions</h2>
3636
{% for session in sessions %}
3737
<tr>
3838
<td>{{ session.prompt }}</td>
39-
<td>{{ session.status }}</td>
40-
<td>{{ session.current_round }}</td>
39+
<td>{{ humanize_session_status(session.status) }}</td>
40+
<td>{% if session.current_round == 0 %}Not started yet{% else %}Round {{ session.current_round }}{% endif %}</td>
4141
<td>
4242
<div class="actions inline-actions">
4343
<a class="button secondary" href="/sessions/{{ session.id }}/view">Resume session</a>
@@ -55,7 +55,7 @@ <h2>Resume sessions</h2>
5555
<section class="card">
5656
<div class="section-head">
5757
<h2>Experiments</h2>
58-
<span>{{ experiments|length }} total</span>
58+
<span>{{ experiments|length }} shown · {{ experiment_count }} total</span>
5959
</div>
6060
{% if experiments %}
6161
<table class="table">
@@ -71,9 +71,9 @@ <h2>Experiments</h2>
7171
{% for experiment in experiments %}
7272
<tr>
7373
<td>{{ experiment.name }}</td>
74-
<td>{{ experiment.config.sampler }}</td>
75-
<td>{{ experiment.config.updater }}</td>
76-
<td>{{ experiment.config.feedback_mode }}</td>
74+
<td>{{ humanize_token(experiment.config.sampler) }}</td>
75+
<td>{{ humanize_token(experiment.config.updater) }}</td>
76+
<td>{{ humanize_feedback_mode(experiment.config.feedback_mode) }}</td>
7777
</tr>
7878
{% endfor %}
7979
</tbody>

app/frontend/templates/replay.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ <h1>{{ session.prompt }}</h1>
2121
<section class="card">
2222
<div class="section-head">
2323
<h2>Round {{ round.round_index }}</h2>
24-
<span>{{ round.render_status }} · {{ round.candidates|length }} candidates</span>
24+
<span>{{ humanize_token(round.render_status) }} · {{ round.candidates|length }} candidates</span>
2525
</div>
2626
<div class="summary-grid">
2727
<div class="summary-item">
@@ -34,7 +34,7 @@ <h2>Round {{ round.round_index }}</h2>
3434
</div>
3535
<div class="summary-item">
3636
<span class="summary-label">Seed policy</span>
37-
<strong>{{ round.seed_policy }}</strong>
37+
<strong>{{ humanize_token(round.seed_policy) }}</strong>
3838
</div>
3939
</div>
4040
<div class="image-grid">
@@ -43,7 +43,7 @@ <h2>Round {{ round.round_index }}</h2>
4343
<img src="{{ candidate.image_path }}" alt="Candidate {{ candidate.candidate_index }}">
4444
<h3>Candidate {{ candidate.candidate_index + 1 }}</h3>
4545
<p class="candidate-subtitle">{{ candidate.id }}</p>
46-
<p><strong>{{ candidate.sampler_role }}</strong></p>
46+
<p><strong>{{ humanize_token(candidate.sampler_role) }}</strong></p>
4747
</article>
4848
{% endfor %}
4949
</div>

app/frontend/templates/session.html

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<section class="hero compact">
1212
<p class="eyebrow">Session</p>
1313
<h1>{{ session.prompt }}</h1>
14-
<p class="lede">Sampler: {{ session.config.sampler.value }} | Updater: {{ session.config.updater.value }} | Feedback: {{ session.config.feedback_mode.value }}</p>
14+
<p class="lede">Sampler: {{ humanize_token(session.config.sampler) }} | Updater: {{ humanize_token(session.config.updater) }} | Feedback: {{ humanize_feedback_mode(session.config.feedback_mode) }}</p>
1515
<div class="badge-row">
1616
<span class="status-badge" title="The resolved generation backend for this session.">Backend: {{ runtime_diagnostics.backend }}</span>
1717
<span class="status-badge" title="The device currently used for inference.">Device: {{ runtime_diagnostics.active_device or runtime_diagnostics.configured_device or "n/a" }}</span>
@@ -27,7 +27,7 @@ <h1>{{ session.prompt }}</h1>
2727
<section class="card">
2828
<div class="section-head">
2929
<h2>Current state</h2>
30-
<span>Round {{ session.current_round }}</span>
30+
<span>{% if session.current_round == 0 %}Not started yet{% else %}Round {{ session.current_round }}{% endif %}</span>
3131
</div>
3232
<p id="page-status" class="status-message" role="status" aria-live="polite" hidden></p>
3333
<div id="progress-panel" class="progress-panel" hidden>
@@ -46,11 +46,7 @@ <h2>Current state</h2>
4646
</div>
4747
<div class="summary-item">
4848
<span class="summary-label">Feedback mode <span class="help-tip inline" tabindex="0" role="note" aria-label="Feedback mode help" data-tooltip="This controls which feedback widget appears on each candidate card and how your selection is interpreted.">?</span></span>
49-
<strong>{{ session.config.feedback_mode.value }}</strong>
50-
</div>
51-
<div class="summary-item">
52-
<span class="summary-label">Steering vector <span class="help-tip inline" tabindex="0" role="note" aria-label="Steering vector help" data-tooltip="This is the current low-dimensional steering state that the updater moves after each round of feedback.">?</span></span>
53-
<code>{{ session.current_z }}</code>
49+
<strong>{{ humanize_feedback_mode(session.config.feedback_mode) }}</strong>
5450
</div>
5551
</div>
5652
<p class="hint">{% if session.current_round == 0 %}Start by generating the first round. You will see one raw prompt baseline plus additional sampled variants.{% elif session.status == "awaiting_feedback" %}Review this round and submit feedback to unlock the next one.{% else %}Feedback is complete for the current round. You can continue exploring or open replay to review the history.{% endif %}</p>
@@ -78,7 +74,6 @@ <h3>Candidate {{ candidate.candidate_index + 1 }}</h3>
7874
<p class="candidate-subtitle">{{ candidate.id }}</p>
7975
<div class="candidate-meta">
8076
<p>Role <span class="help-tip inline" tabindex="0" role="note" aria-label="Candidate role help" data-tooltip="Baseline is the raw prompt, incumbent is the previous winner, and sampler roles describe how other candidates were proposed.">?</span>: <strong>{{ candidate.sampler_role }}</strong></p>
81-
<p>Steering <span class="help-tip inline" tabindex="0" role="note" aria-label="Candidate steering vector help" data-tooltip="This candidate's proposed steering coordinates for the current round.">?</span>: <code>{{ candidate.z }}</code></p>
8277
</div>
8378
{% if is_current_round and not round.feedback_events %}
8479
<div class="candidate-controls" data-feedback-mode="{{ session.config.feedback_mode.value }}">
@@ -148,7 +143,7 @@ <h3>Candidate {{ candidate.candidate_index + 1 }}</h3>
148143
{% endif %}
149144
</p>
150145
<div class="actions">
151-
<button class="button" id="submit-feedback-button" data-round-id="{{ round.id }}" data-feedback-mode="{{ session.config.feedback_mode.value }}" title="Submit your current preference selections and update the session model.">Submit feedback</button>
146+
<button class="button" id="submit-feedback-button" data-round-id="{{ round.id }}" data-feedback-mode="{{ session.config.feedback_mode.value }}" data-session-id="{{ session.id }}" title="Apply your current preference selections and immediately generate the next round.">Submit feedback and generate next round</button>
152147
</div>
153148
{% elif round.feedback_events %}
154149
<div class="soft-callout">

app/main.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,46 @@
2525
templates = Jinja2Templates(directory=str(Path("app/frontend/templates")))
2626

2727

28+
def humanize_token(value: object) -> str:
29+
"""Turn enum values and slugs into readable UI labels."""
30+
31+
if value is None:
32+
return ""
33+
raw = getattr(value, "value", str(value))
34+
return raw.replace("_", " ").replace("-", " ").strip().title()
35+
36+
37+
def humanize_feedback_mode(value: object) -> str:
38+
mapping = {
39+
"scalar_rating": "Star ratings",
40+
"pairwise": "Pairwise winner vs loser",
41+
"top_k": "Top-k ranking",
42+
"winner_only": "Single winner",
43+
"approve_reject": "Approve / reject",
44+
}
45+
raw = getattr(value, "value", str(value))
46+
return mapping.get(raw, humanize_token(raw))
47+
48+
49+
def humanize_session_status(value: object) -> str:
50+
mapping = {
51+
"created": "Created",
52+
"ready": "Ready for the next round",
53+
"awaiting_feedback": "Waiting for feedback",
54+
"updating": "Applying feedback",
55+
"completed": "Completed",
56+
"failed": "Needs attention",
57+
"paused": "Paused",
58+
}
59+
raw = getattr(value, "value", str(value))
60+
return mapping.get(raw, humanize_token(raw))
61+
62+
63+
templates.env.globals["humanize_token"] = humanize_token
64+
templates.env.globals["humanize_feedback_mode"] = humanize_feedback_mode
65+
templates.env.globals["humanize_session_status"] = humanize_session_status
66+
67+
2868
def initialize_app_state(application: FastAPI) -> None:
2969
"""Build the runtime services for the web app.
3070
@@ -129,7 +169,15 @@ def index(request: Request) -> HTMLResponse:
129169

130170
experiments = request.app.state.orchestrator.list_experiments()
131171
sessions = request.app.state.orchestrator.list_sessions()
132-
return templates.TemplateResponse("index.html", {"request": request, "experiments": experiments, "sessions": sessions[:10]})
172+
return templates.TemplateResponse(
173+
"index.html",
174+
{
175+
"request": request,
176+
"experiments": experiments[:20],
177+
"experiment_count": len(experiments),
178+
"sessions": sessions[:10],
179+
},
180+
)
133181

134182

135183
@app.get("/diagnostics")
@@ -292,6 +340,13 @@ def create_session_from_setup(request: SetupSessionRequest):
292340
return api_error_response(400, "invalid_input", str(exc))
293341
except KeyError as exc:
294342
return api_error_response(404, "not_found", str(exc))
343+
except Exception:
344+
logger.exception("Unexpected failure while creating a session from setup YAML")
345+
return api_error_response(
346+
500,
347+
"internal_error",
348+
"The session could not be created from this YAML configuration due to an unexpected server error.",
349+
)
295350
return {
296351
"experiment": experiment.model_dump(mode="json"),
297352
"session": session.model_dump(mode="json"),

0 commit comments

Comments
 (0)