Spaces:
Running
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>
- app/main.py +54 -15
- tests/app/test_render.py +29 -0
|
@@ -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)
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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():
|
|
@@ -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):
|