Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions services/chatbot/src/chatbot/agent_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,31 @@
CHARS_PER_TOKEN = 4


def message_content_to_text(content) -> str:
"""Normalize an LLM message's ``content`` to plain text.

The OpenAI provider and older models return ``content`` as a ``str``, but
newer models (e.g. recent Anthropic Claude models) return it as a list of
structured content blocks (text / thinking / tool-use / signed text).
Downstream the value is stored in the Mongo chat history and passed to
``Document(page_content=...)``, which requires a ``str`` -- a list makes it
raise ``pydantic.ValidationError`` and the whole turn is lost (chat appears
to fail and nothing is persisted). Join the text blocks and drop the rest
so output from any provider is handled.
"""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") == "text":
parts.append(block.get("text", ""))
return "".join(parts)
return "" if content is None else str(content)


def collect_long_strings(obj):
field_info = []

Expand Down
11 changes: 7 additions & 4 deletions services/chatbot/src/chatbot/chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from langgraph.graph.message import Messages

from .agent_utils import trim_messages_to_token_limit
from .agent_utils import message_content_to_text, trim_messages_to_token_limit
from .config import Config
from .extensions import db
from .langgraph_agent import execute_langgraph_agent
Expand Down Expand Up @@ -64,9 +64,12 @@ async def process_user_message(session_id, user_message, api_key, model_name, us
logger.debug("Response messages count: %d", len(response.get("messages", [])))

reply: Messages = response.get("messages", [{}])[-1]
# Newer models can return `content` as a list of structured blocks rather
# than a plain string; normalize it before storing / serializing it.
reply_text = message_content_to_text(reply.content)
response_message_id = uuid4().int & (1 << 63) - 1
history.append(
{"id": response_message_id, "role": "assistant", "content": reply.content}
{"id": response_message_id, "role": "assistant", "content": reply_text}
)
logger.debug(
"Added assistant response to history - message_id: %s", response_message_id
Expand All @@ -75,7 +78,7 @@ async def process_user_message(session_id, user_message, api_key, model_name, us
add_to_chroma_collection(
api_key,
session_id,
[{"user": user_message}, {"assistant": reply.content}],
[{"user": user_message}, {"assistant": reply_text}],
Config.LLM_PROVIDER,
model_name,
)
Expand All @@ -89,4 +92,4 @@ async def process_user_message(session_id, user_message, api_key, model_name, us
response_message_id,
len(history),
)
return reply.content, response_message_id
return reply_text, response_message_id
42 changes: 42 additions & 0 deletions services/chatbot/src/chatbot/tests/test_agent_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
CHARS_PER_TOKEN,
_estimate_tokens,
_message_content,
message_content_to_text,
trim_messages_to_token_limit,
)

Expand Down Expand Up @@ -190,3 +191,44 @@ def test_does_not_mutate_original(self):
original_len = len(msgs)
trim_messages_to_token_limit(msgs)
assert len(msgs) == original_len


# ---------------------------------------------------------------------------
# message_content_to_text
# ---------------------------------------------------------------------------

class TestMessageContentToText:
def test_plain_string_passthrough(self):
assert message_content_to_text("hello") == "hello"

def test_list_of_text_blocks(self):
# Newer Anthropic models return signed text blocks as a list.
content = [{"type": "text", "text": "OK", "signature": "CAQS..."}]
assert message_content_to_text(content) == "OK"

def test_skips_non_text_blocks(self):
# thinking / tool_use blocks must be dropped, text kept and joined.
content = [
{"type": "thinking", "thinking": "...", "signature": "x"},
{"type": "tool_use", "name": "sql", "input": {}},
{"type": "text", "text": "Answer"},
]
assert message_content_to_text(content) == "Answer"

def test_joins_multiple_text_blocks(self):
content = [{"type": "text", "text": "a"}, {"type": "text", "text": "b"}]
assert message_content_to_text(content) == "ab"

def test_list_with_bare_strings(self):
assert message_content_to_text(["a", "b"]) == "ab"

def test_none_returns_empty_string(self):
assert message_content_to_text(None) == ""

def test_other_types_coerced_to_str(self):
assert message_content_to_text(123) == "123"

def test_result_is_always_a_string(self):
# The contract the callers rely on (Document(page_content=...) needs str).
for value in ["x", [{"type": "text", "text": "y"}], None, 1, []]:
assert isinstance(message_content_to_text(value), str)