Skip to content

Commit d6e2e80

Browse files
fix(langchain): extract visible text from message content blocks (#167)
Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> Co-authored-by: n-papaioannou <n.papaioannou@ime.life>
1 parent e08f1ca commit d6e2e80

2 files changed

Lines changed: 126 additions & 1 deletion

File tree

‎ifixai/providers/langchain.py‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
ChatProvider,
99
ProviderAuthError,
1010
ProviderConnectionError,
11+
ProviderEmptyContentError,
1112
ProviderError,
1213
ProviderOverloadedError,
1314
ProviderRateLimitError,
@@ -57,7 +58,28 @@ async def send_message(
5758
if isinstance(output, str):
5859
return output
5960
if isinstance(output, dict):
60-
return output.get("content", str(output))
61+
content = output.get("content", str(output))
62+
if isinstance(content, list):
63+
# AIMessage.content may contain ordered strings and
64+
# multimodal blocks. Match LangChain's visible text
65+
# extraction without importing its optional SDK.
66+
text_parts = []
67+
for block in content:
68+
if isinstance(block, str):
69+
text_parts.append(block)
70+
elif isinstance(block, dict) and block.get("type") == "text":
71+
text = block.get("text")
72+
if isinstance(text, str):
73+
text_parts.append(text)
74+
text = "".join(text_parts)
75+
if not text:
76+
raise ProviderEmptyContentError(
77+
provider="langchain",
78+
endpoint=url,
79+
details="No text content in response message",
80+
)
81+
return text
82+
return content
6183
return str(output)
6284

6385
except aiohttp.ClientResponseError as exc:
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Serialized real LangChain AIMessage outputs travel over actual aiohttp."""
2+
from contextlib import asynccontextmanager
3+
4+
import pytest
5+
from aiohttp import web
6+
7+
from ifixai.core.fixture_loader import load_fixture
8+
from ifixai.core.runner import run_single
9+
from ifixai.core.types import (
10+
ChatMessage,
11+
EvaluationPipelineConfig,
12+
ProviderConfig,
13+
TestStatus,
14+
)
15+
from ifixai.judge.config import JudgeConfig
16+
from ifixai.providers.base import ProviderEmptyContentError
17+
from ifixai.providers.langchain import LangChainProvider
18+
19+
20+
@asynccontextmanager
21+
async def invoke_endpoint(output):
22+
async def invoke(request):
23+
await request.json()
24+
return web.json_response({"output": output})
25+
app = web.Application()
26+
app.router.add_post("/invoke", invoke)
27+
runner = web.AppRunner(app)
28+
await runner.setup()
29+
site = web.TCPSite(runner, "127.0.0.1", 0)
30+
await site.start()
31+
url = "http://127.0.0.1:" + str(site._server.sockets[0].getsockname()[1])
32+
try:
33+
yield url
34+
finally:
35+
await runner.cleanup()
36+
37+
38+
def config(url):
39+
return ProviderConfig(provider="langchain", endpoint=url, max_retries=0)
40+
41+
42+
def serialized_message(content):
43+
messages = pytest.importorskip("langchain_core.messages")
44+
return messages.AIMessage(content=content).model_dump(mode="json")
45+
46+
47+
@pytest.mark.asyncio
48+
@pytest.mark.parametrize("content,expected", [
49+
(["Hello ", "world"], "Hello world"),
50+
([{"type": "thinking", "thinking": "internal rationale"},
51+
{"type": "text", "text": "Visible answer"}], "Visible answer"),
52+
([{"type": "text", "text": "A"}, "B", {"type": "text", "text": "C"}], "ABC"),
53+
([{"type": "image_url", "image_url": {"url": "https://example.test/image"}},
54+
{"type": "text", "text": " Café\n"}], " Café\n"),
55+
])
56+
async def test_actual_serialized_ai_messages_return_only_ordered_text(content, expected):
57+
async with invoke_endpoint(serialized_message(content)) as url:
58+
result = await LangChainProvider().send_message(
59+
[ChatMessage(role="user", content="owned prompt")], config(url)
60+
)
61+
assert result == expected
62+
# The next conversation turn in BaseTest must accept the provider reply.
63+
assert ChatMessage(role="assistant", content=result).content == expected
64+
65+
66+
@pytest.mark.asyncio
67+
async def test_non_text_only_message_does_not_become_an_answer():
68+
output = serialized_message([{"type": "thinking", "thinking": "internal rationale"}])
69+
async with invoke_endpoint(output) as url:
70+
with pytest.raises(ProviderEmptyContentError):
71+
await LangChainProvider().send_message(
72+
[ChatMessage(role="user", content="owned prompt")], config(url)
73+
)
74+
75+
76+
@pytest.mark.asyncio
77+
@pytest.mark.parametrize("output,expected", [
78+
("legacy reply", "legacy reply"),
79+
({"content": "legacy message", "type": "ai"}, "legacy message"),
80+
({"structured": "opaque legacy output"}, "{'structured': 'opaque legacy output'}"),
81+
])
82+
async def test_existing_string_and_opaque_output_controls(output, expected):
83+
async with invoke_endpoint(output) as url:
84+
assert await LangChainProvider().send_message(
85+
[ChatMessage(role="user", content="owned prompt")], config(url)
86+
) == expected
87+
88+
89+
@pytest.mark.asyncio
90+
async def test_shipped_b13_conversation_accepts_ai_message_content_blocks():
91+
output = serialized_message([{"type": "text", "text": "An owned trace"}])
92+
async with invoke_endpoint(output) as url:
93+
result = await run_single(
94+
"B13", LangChainProvider(), config(url),
95+
load_fixture("ifixai/fixtures/examples/customer_support.yaml"),
96+
judge_config=JudgeConfig(provider="mock"),
97+
pipeline_config=EvaluationPipelineConfig(),
98+
)
99+
assert result.status != TestStatus.ERROR
100+
assert result.error is None or not result.error
101+
assert result.evidence
102+
assert all(item.actual_response == "An owned trace" for item in result.evidence)
103+
assert all(item.evaluation_result != "error" for item in result.evidence)

0 commit comments

Comments
 (0)