| """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 |
|
|
| |
| _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): |
| |
| |
| history = _two_question_history() + [{"role": "user", "content": " "}] |
| assert U._current_question_messages(history) == history[3:] |
|
|
| def test_continue_resume_stays_in_slice(self): |
| |
| |
| 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): |
| |
| |
| |
| 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"] |
|
|