Paper2Agent_decoupleRpy / tests /test_export_scope.py
Annie Voigt
fix(ui): scope exports to the current question's run, not the whole session
e517a43
Raw
History Blame Contribute Delete
4.5 kB
"""Exports are scoped to the most recent question's run (TODO 2026-08-11 #13).
Regression cover for the stale-figure bug: a session accumulates questions in
one chat history, and the export builders swept the WHOLE history for inline
figures — so the report for question 2 embedded question 1's volcano/Hallmark
plots, which contradicted question 2's tables. `_current_question_messages`
slices the history from the last non-empty user message onward and every
export path is built from that slice.
Small synthetic messages only — no network, no Gradio runtime.
"""
import base64
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from ui_formatting import _UIFormattingMixin as U # noqa: E402
# A valid 1x1 PNG so _extract_images' base64 decode succeeds.
_PNG_B64 = base64.b64encode(
base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
"AAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
).decode()
def _figure_msg(caption):
return {
"role": "assistant",
"content": (
f'<div style="font-style: italic;">{caption}</div>'
f'<img src="data:image/png;base64,{_PNG_B64}" />'
),
}
def _solution_msg(text):
return {
"role": "assistant",
"content": (
'<div class="solution-header">✅ Final Solution</div>'
f'<div class="solution-content">{text}</div>'
"<style>.x{}</style>"
),
}
def _two_question_history():
return [
{"role": "user", "content": "Q1: shMyc vs shCntrl DE?"},
_figure_msg("Q1 volcano"),
_solution_msg("Q1: MYC targets down."),
{"role": "user", "content": "Q2: tumor vs liver met?"},
_figure_msg("Q2 heatmap"),
_solution_msg("Q2: bile acid up."),
]
class TestCurrentQuestionMessages:
def test_slices_from_last_user_message(self):
history = _two_question_history()
scoped = U._current_question_messages(history)
assert scoped == history[3:]
def test_no_user_message_returns_all(self):
history = [_figure_msg("orphan"), _solution_msg("s")]
assert U._current_question_messages(history) == history
def test_empty_history(self):
assert U._current_question_messages([]) == []
def test_blank_user_message_ignored(self):
# A trailing empty user turn (e.g. a stray submit) must not create an
# empty slice that drops the real question's figures.
history = _two_question_history() + [{"role": "user", "content": " "}]
assert U._current_question_messages(history) == history[3:]
def test_continue_resume_stays_in_slice(self):
# Continue appends only assistant messages, so a continued run's later
# figures stay inside the current question's slice.
history = _two_question_history() + [_figure_msg("Q2 post-continue barplot")]
scoped = U._current_question_messages(history)
assert scoped[0] == history[3]
assert scoped[-1] == history[-1]
def test_list_content_user_message(self):
history = [
{"role": "user", "content": [{"type": "text", "text": "Q1"}]},
_figure_msg("f1"),
{"role": "user", "content": [{"type": "text", "text": "Q2"}]},
_figure_msg("f2"),
]
assert U._current_question_messages(history) == history[2:]
class TestScopedExports:
def test_extract_images_scoped_excludes_prior_question(self):
scoped = U._current_question_messages(_two_question_history())
images = U._extract_images(scoped)
captions = [c for c, _ in images]
assert captions == ["Q2 heatmap"]
def test_unscoped_extract_shows_the_bug_surface(self):
# Documents why scoping matters: the full history carries both figures
# (deduped on image data here since the test PNG is identical, but the
# first caption wins — i.e. the stale one).
images = U._extract_images(_two_question_history())
assert images[0][0] == "Q1 volcano"
def test_assessment_blocks_scoped_to_current_question(self):
u = U()
scoped = U._current_question_messages(_two_question_history())
blocks = u._assessment_blocks(scoped)
headings = {h: b for h, b, _ in blocks}
assert headings["Question"] == "Q2: tumor vs liver met?"
assert "bile acid" in headings["Solution"]
assert "Q1" not in headings["Question"]