eliezer avihail Claude commited on
Commit
daae350
Β·
unverified Β·
1 Parent(s): a15a9df

app: live bare spinner under the answer during the freshness check (#103)

Browse files

* app: keep the answer on screen with a live verifying spinner during the freshness check

Review feedback on the SWR flow: while the post-answer freshness check ran,
the user saw a bare answer with no sign anything was happening, and a
correction (when it came) appeared out of nowhere. Now the answer stays fully
visible the whole time with a small animated line UNDER it β€” 'Checking these
docs are still current…' β€” updated in place through the same Gradio stream
(no page refresh, never a blank screen). A clean check simply removes the
line; on drift the line switches to 'The docs changed β€” updating this
answer…' while the regeneration runs, then the corrected answer swaps in with
the note. Thread exceptions are logged and always fall back to clearing the
spinner and keeping the shown answer.

227 tests pass (added: the answer remains in every verifying frame, and the
spinner line is gone from the final value).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LX1gm8fuzK2ZeEWi3Zar1b

* app: bare spinner under the answer β€” just the wheel, no words

Per feedback: the verifying/updating line under the answer now shows only the
animated wheel, no explanatory text. The final ↻ note still explains a
correction after the fact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LX1gm8fuzK2ZeEWi3Zar1b

---------

Co-authored-by: Claude <noreply@anthropic.com>

Files changed (2) hide show
  1. app/main.py +54 -15
  2. tests/app/test_render.py +29 -0
app/main.py CHANGED
@@ -190,13 +190,18 @@ def respond(question: str, request: gr.Request = None):
190
  reads as "working", not "frozen" β€” then yields the finished markdown.
191
 
192
  After the answer is shown, a stale-while-revalidate pass re-checks the
193
- CITED pages against the live docs (index/freshness.py): the stored copies
194
- self-heal on drift, and if the grounding text really changed the answer is
195
- regenerated from the fresh content and swapped in with a note. The user
196
- already has an answer during all of this, and any freshness failure leaves
197
- it untouched. The first yield is the static THINKING_NOTE (immediate
198
- paint); gradio_client.predict returns the LAST yielded value, so the smoke
199
- test still gets a real answer.
 
 
 
 
 
200
  """
201
  yield THINKING_NOTE
202
 
@@ -221,26 +226,60 @@ def respond(question: str, request: gr.Request = None):
221
  time.monotonic() - started < _DRAFTING_AFTER
222
  ) else "Drafting your answer"
223
  yield f"{next(frames)} {stage}…"
224
- yield result.get("md", ERROR_NOTE)
 
225
 
226
  # ---- stale-while-revalidate: the answer is already on screen ----------
 
 
 
227
  try:
228
- answer = result.get("answer")
229
- if answer is None or not answer.citations:
230
- return
231
  from index import freshness
232
 
233
  if not freshness.enabled():
234
  return
235
- drifted = freshness.refresh_pages([c.url for c in answer.citations])
236
- if not drifted:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  return
 
238
  # the text the answer was grounded in changed β†’ answer again from the
239
  # just-refreshed content and swap it in, flagged so the user knows why
240
- print(f"[app] cited docs drifted ({len(drifted)}); regenerating", flush=True)
241
- yield render(answer_routed(question)) + FRESHNESS_NOTE
 
 
 
 
 
 
 
 
 
242
  except Exception as exc: # noqa: BLE001 β€” never disturb the shown answer
243
  print(f"[app] freshness pass skipped: {type(exc).__name__}: {exc}", flush=True)
 
244
 
245
 
246
  def build_ui():
 
190
  reads as "working", not "frozen" β€” then yields the finished markdown.
191
 
192
  After the answer is shown, a stale-while-revalidate pass re-checks the
193
+ CITED pages against the live docs (index/freshness.py). The answer stays
194
+ fully visible the whole time, with a bare spinner (just the wheel, no
195
+ words) under it while the check runs β€” the user reads while we verify β€”
196
+ and everything swaps in place (Gradio streams the same output component;
197
+ no page refresh). Clean check β†’ the spinner simply disappears. Drift β†’ the
198
+ stored copies self-heal, and the answer is regenerated from the fresh
199
+ content and swapped in with the ↻ note (the spinner keeps turning during
200
+ the regeneration). Any freshness failure clears the spinner and leaves the
201
+ shown answer untouched.
202
+ The first yield is the static THINKING_NOTE (immediate paint);
203
+ gradio_client.predict returns the LAST yielded value, so the smoke test
204
+ still gets a real answer.
205
  """
206
  yield THINKING_NOTE
207
 
 
226
  time.monotonic() - started < _DRAFTING_AFTER
227
  ) else "Drafting your answer"
228
  yield f"{next(frames)} {stage}…"
229
+ md = result.get("md", ERROR_NOTE)
230
+ yield md
231
 
232
  # ---- stale-while-revalidate: the answer is already on screen ----------
233
+ answer = result.get("answer")
234
+ if answer is None or not answer.citations:
235
+ return
236
  try:
 
 
 
237
  from index import freshness
238
 
239
  if not freshness.enabled():
240
  return
241
+
242
+ def run_below(target):
243
+ """Run `target` on a thread; while it runs, keep the ANSWER on
244
+ screen with a bare spinner under it β€” just the wheel, no words
245
+ (in-place updates: never a blank screen, never a page refresh)."""
246
+ thread = threading.Thread(target=target, daemon=True)
247
+ thread.start()
248
+ while True:
249
+ thread.join(timeout=THINKING_TICK)
250
+ if not thread.is_alive():
251
+ return
252
+ yield f"{md}\n\n<sub>{next(frames)}</sub>"
253
+
254
+ check: dict = {}
255
+
256
+ def verify():
257
+ try:
258
+ check["drifted"] = freshness.refresh_pages([c.url for c in answer.citations])
259
+ except Exception as exc: # noqa: BLE001 β€” a thread exception must not vanish
260
+ print(f"[app] freshness check failed: {type(exc).__name__}: {exc}", flush=True)
261
+
262
+ yield from run_below(verify)
263
+ if not check.get("drifted"):
264
+ yield md # clean (or check failed): just clear the spinner line
265
  return
266
+
267
  # the text the answer was grounded in changed β†’ answer again from the
268
  # just-refreshed content and swap it in, flagged so the user knows why
269
+ print("[app] cited docs drifted; regenerating", flush=True)
270
+ redo: dict = {}
271
+
272
+ def regenerate():
273
+ try:
274
+ redo["md"] = render(answer_routed(question)) + FRESHNESS_NOTE
275
+ except Exception as exc: # noqa: BLE001 β€” keep the original answer instead
276
+ print(f"[app] regeneration failed: {type(exc).__name__}: {exc}", flush=True)
277
+
278
+ yield from run_below(regenerate)
279
+ yield redo.get("md", md) # a failed regeneration keeps the original
280
  except Exception as exc: # noqa: BLE001 β€” never disturb the shown answer
281
  print(f"[app] freshness pass skipped: {type(exc).__name__}: {exc}", flush=True)
282
+ yield md # clear any spinner line that was showing
283
 
284
 
285
  def build_ui():
tests/app/test_render.py CHANGED
@@ -102,8 +102,37 @@ def test_respond_regenerates_when_the_cited_docs_drifted(monkeypatch):
102
  def test_respond_freshness_is_silent_when_nothing_drifted(monkeypatch):
103
  monkeypatch.setattr("app.main.answer_routed", lambda q, **k: _cited_answer("the answer"))
104
  monkeypatch.setattr("index.freshness.refresh_pages", lambda urls: set())
 
 
105
  chunks = list(respond("how do I use SGD?"))
106
  assert "the answer" in chunks[-1] and "regenerated" not in chunks[-1]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
 
109
  def test_respond_freshness_failure_never_disturbs_the_answer(monkeypatch):
 
102
  def test_respond_freshness_is_silent_when_nothing_drifted(monkeypatch):
103
  monkeypatch.setattr("app.main.answer_routed", lambda q, **k: _cited_answer("the answer"))
104
  monkeypatch.setattr("index.freshness.refresh_pages", lambda urls: set())
105
+ from app.main import THINKING_SPINNER
106
+
107
  chunks = list(respond("how do I use SGD?"))
108
  assert "the answer" in chunks[-1] and "regenerated" not in chunks[-1]
109
+ # the spinner under the answer is cleared once the check finishes
110
+ assert all(frame not in chunks[-1] for frame in THINKING_SPINNER)
111
+
112
+
113
+ def test_respond_keeps_the_answer_visible_while_verifying(monkeypatch):
114
+ # while the freshness check runs, the user must keep READING the answer β€”
115
+ # under it just a bare spinner (the wheel, no words), updated in place
116
+ import time
117
+
118
+ from app.main import THINKING_SPINNER
119
+
120
+ monkeypatch.setattr("app.main.THINKING_TICK", 0.02)
121
+ monkeypatch.setattr("app.main.answer_routed", lambda q, **k: _cited_answer("the answer"))
122
+
123
+ def slow_check(urls):
124
+ time.sleep(0.15) # several ticks β†’ several verifying frames
125
+ return set()
126
+
127
+ monkeypatch.setattr("index.freshness.refresh_pages", slow_check)
128
+ chunks = list(respond("how do I use SGD?"))
129
+ verifying = [
130
+ c for c in chunks if "the answer" in c and any(f in c for f in THINKING_SPINNER)
131
+ ]
132
+ assert verifying # the check was visible: answer + wheel together
133
+ # the wheel is BARE β€” no explanatory text rides along with it
134
+ assert all(c.endswith("</sub>") and "…" not in c.split("<sub>")[1] for c in verifying)
135
+ assert all(f not in chunks[-1] for f in THINKING_SPINNER) # gone when done
136
 
137
 
138
  def test_respond_freshness_failure_never_disturbs_the_answer(monkeypatch):