render attached pictures in inspector context modal under qa.model.vision
This commit is contained in:
parent
7fac35d2af
commit
75f75ec505
2 changed files with 143 additions and 10 deletions
|
|
@ -1,10 +1,14 @@
|
||||||
|
import base64
|
||||||
|
from io import BytesIO
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from PIL import Image as PILImage
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from textual.containers import VerticalScroll
|
from textual.containers import VerticalScroll
|
||||||
from textual.screen import Screen
|
from textual.screen import Screen
|
||||||
from textual.widgets import Markdown, Static
|
from textual.widgets import Markdown, Static
|
||||||
|
from textual_image.widget import Image as TextualImage
|
||||||
|
|
||||||
from haiku.rag.store.models import SearchResult
|
from haiku.rag.store.models import SearchResult
|
||||||
|
|
||||||
|
|
@ -42,6 +46,17 @@ class ContextModal(Screen):
|
||||||
#context-content Markdown {
|
#context-content Markdown {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#context-content Image {
|
||||||
|
margin: 1 0;
|
||||||
|
height: auto;
|
||||||
|
max-height: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picture-caption {
|
||||||
|
color: $text-muted;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, chunk: "Chunk", client: "HaikuRAG"):
|
def __init__(self, chunk: "Chunk", client: "HaikuRAG"):
|
||||||
|
|
@ -56,8 +71,6 @@ class ContextModal(Screen):
|
||||||
yield self._content_widget
|
yield self._content_widget
|
||||||
|
|
||||||
async def on_mount(self) -> None:
|
async def on_mount(self) -> None:
|
||||||
"""Load and display the expanded context."""
|
|
||||||
# Create a SearchResult from the chunk
|
|
||||||
chunk_meta = self.chunk.get_chunk_metadata()
|
chunk_meta = self.chunk.get_chunk_metadata()
|
||||||
search_result = SearchResult(
|
search_result = SearchResult(
|
||||||
content=self.chunk.content,
|
content=self.chunk.content,
|
||||||
|
|
@ -72,18 +85,45 @@ class ContextModal(Screen):
|
||||||
labels=chunk_meta.labels,
|
labels=chunk_meta.labels,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Expand context using the client (this is what agents actually receive)
|
|
||||||
expanded_results = await self.client.expand_context([search_result])
|
expanded_results = await self.client.expand_context([search_result])
|
||||||
expanded = expanded_results[0] if expanded_results else search_result
|
expanded = expanded_results[0] if expanded_results else search_result
|
||||||
|
|
||||||
formatted = expanded.format_for_agent()
|
vision = self.client._config.qa.model.vision
|
||||||
|
attached = expanded.image_data or {}
|
||||||
|
|
||||||
content = (
|
if vision and attached:
|
||||||
"*This is how the chunk appears to agents after context expansion:*\n\n---\n\n"
|
preface = (
|
||||||
f"{formatted}"
|
f"*This is what the LLM receives. Vision is enabled — {len(attached)} "
|
||||||
|
"picture(s) are attached as image content (rendered below).*"
|
||||||
|
)
|
||||||
|
elif vision:
|
||||||
|
preface = (
|
||||||
|
"*This is what the LLM receives. Vision is enabled, but no pictures "
|
||||||
|
"are in this expanded context.*"
|
||||||
|
)
|
||||||
|
elif attached:
|
||||||
|
preface = (
|
||||||
|
f"*This is what the LLM receives. Vision is disabled — {len(attached)} "
|
||||||
|
"picture(s) are suppressed; any VLM descriptions remain inline below.*"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
preface = "*This is what the LLM receives.*"
|
||||||
|
|
||||||
|
await self._content_widget.update(
|
||||||
|
f"{preface}\n\n---\n\n{expanded.format_for_agent()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
await self._content_widget.update(content)
|
if vision and attached:
|
||||||
|
scroll = self.query_one("#context-content", VerticalScroll)
|
||||||
|
for self_ref, b64 in attached.items():
|
||||||
|
try:
|
||||||
|
pil = PILImage.open(BytesIO(base64.b64decode(b64)))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
await scroll.mount(
|
||||||
|
Static(f"[b]{self_ref}[/b]", classes="picture-caption")
|
||||||
|
)
|
||||||
|
await scroll.mount(TextualImage(pil))
|
||||||
|
|
||||||
async def action_dismiss(self, result=None) -> None:
|
async def action_dismiss(self, result=None) -> None:
|
||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,47 @@
|
||||||
from unittest.mock import AsyncMock, patch
|
import base64
|
||||||
|
from io import BytesIO
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from PIL import Image as PILImage
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from haiku.rag.cli import _cli as cli
|
from haiku.rag.cli import _cli as cli
|
||||||
from haiku.rag.store.models import Document
|
from haiku.rag.store.models import Chunk, Document, SearchResult
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
def _png_b64(color: str = "red", size: tuple[int, int] = (8, 8)) -> str:
|
||||||
|
img = PILImage.new("RGB", size, color)
|
||||||
|
buf = BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
return base64.b64encode(buf.getvalue()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client(*, vision: bool, image_data: dict[str, str] | None) -> MagicMock:
|
||||||
|
"""Stub HaikuRAG that returns one expanded SearchResult with the given attachments."""
|
||||||
|
from haiku.rag.config import AppConfig
|
||||||
|
|
||||||
|
config = AppConfig()
|
||||||
|
config.qa.model.vision = vision
|
||||||
|
|
||||||
|
expanded = SearchResult(
|
||||||
|
content="expanded text incl. picture descriptions if any",
|
||||||
|
score=0.5,
|
||||||
|
chunk_id="chunk-1",
|
||||||
|
document_id="doc-1",
|
||||||
|
doc_item_refs=["#/texts/0"] + (list(image_data) if image_data else []),
|
||||||
|
page_numbers=[1],
|
||||||
|
labels=["paragraph"] + (["picture"] if image_data else []),
|
||||||
|
image_data=image_data,
|
||||||
|
)
|
||||||
|
client = MagicMock()
|
||||||
|
client._config = config
|
||||||
|
client.expand_context = AsyncMock(return_value=[expanded])
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
def test_inspect_command():
|
def test_inspect_command():
|
||||||
"""Test inspect command launches inspector TUI."""
|
"""Test inspect command launches inspector TUI."""
|
||||||
with patch("haiku.rag.inspector.run_inspector") as mock_inspector:
|
with patch("haiku.rag.inspector.run_inspector") as mock_inspector:
|
||||||
|
|
@ -130,3 +163,63 @@ async def test_document_list_tracks_has_more():
|
||||||
await doc_list.load_more(mock_client)
|
await doc_list.load_more(mock_client)
|
||||||
# After loading partial batch (<50), has_more should be False
|
# After loading partial batch (<50), has_more should be False
|
||||||
assert doc_list.has_more is False
|
assert doc_list.has_more is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_context_modal_renders_pictures_when_vision_enabled():
|
||||||
|
"""ContextModal must mount one TextualImage per attached picture
|
||||||
|
when qa.model.vision is True — that's what the LLM actually sees."""
|
||||||
|
from textual.app import App
|
||||||
|
from textual_image.widget import Image as TextualImage
|
||||||
|
|
||||||
|
from haiku.rag.inspector.widgets.context_modal import ContextModal
|
||||||
|
|
||||||
|
chunk = Chunk(
|
||||||
|
id="chunk-1", document_id="doc-1", content="raw chunk text", metadata={}
|
||||||
|
)
|
||||||
|
client = _make_client(
|
||||||
|
vision=True,
|
||||||
|
image_data={"#/pictures/0": _png_b64("red"), "#/pictures/1": _png_b64("blue")},
|
||||||
|
)
|
||||||
|
|
||||||
|
class TestApp(App):
|
||||||
|
async def on_mount(self) -> None:
|
||||||
|
await self.push_screen(ContextModal(chunk=chunk, client=client))
|
||||||
|
|
||||||
|
app = TestApp()
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.pause()
|
||||||
|
modal = app.screen
|
||||||
|
images = list(modal.query(TextualImage))
|
||||||
|
assert len(images) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_context_modal_suppresses_pictures_when_vision_disabled():
|
||||||
|
"""ContextModal must NOT mount picture widgets when vision is off,
|
||||||
|
even if expansion attached image_data — text-only models would never
|
||||||
|
see those bytes, so the inspector shouldn't show them either."""
|
||||||
|
from textual.app import App
|
||||||
|
from textual_image.widget import Image as TextualImage
|
||||||
|
|
||||||
|
from haiku.rag.inspector.widgets.context_modal import ContextModal
|
||||||
|
|
||||||
|
chunk = Chunk(
|
||||||
|
id="chunk-1", document_id="doc-1", content="raw chunk text", metadata={}
|
||||||
|
)
|
||||||
|
client = _make_client(
|
||||||
|
vision=False,
|
||||||
|
image_data={"#/pictures/0": _png_b64("red")},
|
||||||
|
)
|
||||||
|
|
||||||
|
class TestApp(App):
|
||||||
|
async def on_mount(self) -> None:
|
||||||
|
await self.push_screen(ContextModal(chunk=chunk, client=client))
|
||||||
|
|
||||||
|
app = TestApp()
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.pause()
|
||||||
|
modal = app.screen
|
||||||
|
assert list(modal.query(TextualImage)) == []
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue