Skip to content

Commit 7248b9f

Browse files
authored
Update trace normalization to ChatML content blocks (#283)
1 parent 932e1a1 commit 7248b9f

File tree

4 files changed

+85
-15
lines changed

4 files changed

+85
-15
lines changed

docs/assets/recipes/mcp_and_tooluse/pdf_qa.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,10 +391,31 @@ def _truncate(text: str, max_length: int = 100) -> str:
391391
return text[: max_length - 3] + "..."
392392

393393

394+
def _summarize_content(content: object) -> str:
395+
"""Summarize ChatML-style content blocks for display."""
396+
if isinstance(content, list):
397+
parts: list[str] = []
398+
for block in content:
399+
if isinstance(block, dict):
400+
block_type = block.get("type", "block")
401+
if block_type == "text":
402+
text = str(block.get("text", ""))
403+
if text:
404+
parts.append(text)
405+
elif block_type == "image_url":
406+
parts.append("[image]")
407+
else:
408+
parts.append(f"[{block_type}]")
409+
else:
410+
parts.append(str(block))
411+
return " ".join(parts)
412+
return str(content)
413+
414+
394415
def _format_trace_step(msg: dict[str, object]) -> str:
395416
"""Format a single trace message as a concise one-liner."""
396417
role = msg.get("role", "unknown")
397-
content = msg.get("content", "")
418+
content = _summarize_content(msg.get("content", ""))
398419
reasoning = msg.get("reasoning_content")
399420
tool_calls = msg.get("tool_calls")
400421
tool_call_id = msg.get("tool_call_id")

docs/concepts/traces.md

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,10 @@ Each trace is a `list[dict]` where each dict represents a message in the convers
6868

6969
| Role | Fields | Description |
7070
|------|--------|-------------|
71-
| `system` | `role`, `content` | System prompt setting model behavior |
72-
| `user` | `role`, `content` | User prompt (rendered from template) |
73-
| `assistant` | `role`, `content`, `tool_calls`, `reasoning_content` | Model response; `content` may be `None` if only requesting tools |
74-
| `tool` | `role`, `content`, `tool_call_id` | Tool execution result; `tool_call_id` links to the request |
71+
| `system` | `role`, `content` | System prompt setting model behavior. `content` is a list of blocks in ChatML format. |
72+
| `user` | `role`, `content` | User prompt (rendered from template). `content` is a list of blocks (text + multimodal). |
73+
| `assistant` | `role`, `content`, `tool_calls`, `reasoning_content` | Model response; `content` may be empty if only requesting tools. |
74+
| `tool` | `role`, `content`, `tool_call_id` | Tool execution result; `tool_call_id` links to the request. |
7575

7676
### Example Trace (Simple Generation)
7777

@@ -82,17 +82,17 @@ A basic trace without tool use:
8282
# System message (if configured)
8383
{
8484
"role": "system",
85-
"content": "You are a helpful assistant that provides clear, concise answers."
85+
"content": [{"type": "text", "text": "You are a helpful assistant that provides clear, concise answers."}]
8686
},
8787
# User message (the rendered prompt)
8888
{
8989
"role": "user",
90-
"content": "What is the capital of France?"
90+
"content": [{"type": "text", "text": "What is the capital of France?"}]
9191
},
9292
# Final assistant response
9393
{
9494
"role": "assistant",
95-
"content": "The capital of France is Paris.",
95+
"content": [{"type": "text", "text": "The capital of France is Paris."}],
9696
"reasoning_content": None # May contain reasoning if model supports it
9797
}
9898
]
@@ -107,17 +107,17 @@ When tool use is enabled, traces capture the full conversation including tool ca
107107
# System message
108108
{
109109
"role": "system",
110-
"content": "You must call tools before answering. Only use tool results."
110+
"content": [{"type": "text", "text": "You must call tools before answering. Only use tool results."}]
111111
},
112112
# User message (the rendered prompt)
113113
{
114114
"role": "user",
115-
"content": "What documents are in the knowledge base about machine learning?"
115+
"content": [{"type": "text", "text": "What documents are in the knowledge base about machine learning?"}]
116116
},
117117
# Assistant requests tool calls
118118
{
119119
"role": "assistant",
120-
"content": None,
120+
"content": [{"type": "text", "text": ""}],
121121
"tool_calls": [
122122
{
123123
"id": "call_abc123",
@@ -132,13 +132,13 @@ When tool use is enabled, traces capture the full conversation including tool ca
132132
# Tool response (linked by tool_call_id)
133133
{
134134
"role": "tool",
135-
"content": "Found 3 documents: intro_ml.pdf, neural_networks.pdf, transformers.pdf",
135+
"content": [{"type": "text", "text": "Found 3 documents: intro_ml.pdf, neural_networks.pdf, transformers.pdf"}],
136136
"tool_call_id": "call_abc123"
137137
},
138138
# Final assistant response
139139
{
140140
"role": "assistant",
141-
"content": "The knowledge base contains three documents about machine learning: ..."
141+
"content": [{"type": "text", "text": "The knowledge base contains three documents about machine learning: ..."}]
142142
}
143143
]
144144
```

packages/data-designer-engine/src/data_designer/engine/models/utils.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,14 @@ class ChatMessage:
3636
def to_dict(self) -> dict[str, Any]:
3737
"""Convert the message to a dictionary format for API calls.
3838
39+
Content is normalized to a list of ChatML-style blocks to keep a
40+
consistent schema across traces and API payloads.
41+
3942
Returns:
4043
A dictionary containing the message fields. Only includes non-empty
4144
optional fields to keep the output clean.
4245
"""
43-
result: dict[str, Any] = {"role": self.role, "content": self.content}
46+
result: dict[str, Any] = {"role": self.role, "content": _normalize_content_blocks(self.content)}
4447
if self.reasoning_content:
4548
result["reasoning_content"] = self.reasoning_content
4649
if self.tool_calls:
@@ -99,3 +102,27 @@ def prompt_to_messages(
99102
if system_prompt:
100103
return [ChatMessage.as_system(system_prompt), ChatMessage.as_user(user_content)]
101104
return [ChatMessage.as_user(user_content)]
105+
106+
107+
def _normalize_content_blocks(content: Any) -> list[dict[str, Any]]:
108+
if isinstance(content, list):
109+
return [_normalize_content_block(block) for block in content]
110+
if content is None:
111+
return []
112+
return [_text_block(content)]
113+
114+
115+
def _normalize_content_block(block: Any) -> dict[str, Any]:
116+
if isinstance(block, dict) and "type" in block:
117+
return block
118+
return _text_block(block)
119+
120+
121+
def _text_block(value: Any) -> dict[str, Any]:
122+
if value is None:
123+
text_value = ""
124+
elif isinstance(value, str):
125+
text_value = value
126+
else:
127+
text_value = str(value)
128+
return {"type": "text", "text": text_value}

packages/data-designer-engine/tests/engine/column_generators/generators/test_llm_completion_generators.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,35 @@ def test_generate_method() -> None:
100100
result = generator.generate(data)
101101

102102
assert result["test_column"] == {"result": "test_output"}
103-
assert result["test_column" + TRACE_COLUMN_POSTFIX] == [{"role": "user", "content": "x"}]
103+
assert result["test_column" + TRACE_COLUMN_POSTFIX] == [
104+
{"role": "user", "content": [{"type": "text", "text": "x"}]}
105+
]
104106

105107
# Test multi-modal context is None
106108
call_args = mock_model.generate.call_args
107109
assert call_args[1]["multi_modal_context"] is None
108110

109111

112+
def test_generate_method_normalizes_trace_content_blocks() -> None:
113+
generator, _, mock_model, _, _, mock_prompt_renderer, mock_response_recipe = _create_generator_with_mocks()
114+
115+
generator.resource_provider.run_config.debug_override_save_all_column_traces = True
116+
mock_prompt_renderer.render.side_effect = ["rendered_user_prompt", "rendered_system_prompt"]
117+
mock_response_recipe.serialize_output.return_value = {"result": "test_output"}
118+
119+
multi_modal_content = [
120+
{"type": "image_url", "image_url": {"url": "https://example.com/image.png"}},
121+
{"type": "text", "text": "describe the image"},
122+
]
123+
mock_model.generate.return_value = ({"result": "test_output"}, [ChatMessage.as_user(multi_modal_content)])
124+
125+
result = generator.generate({"input": "test_input"})
126+
127+
trace = result["test_column" + TRACE_COLUMN_POSTFIX]
128+
assert trace[0]["role"] == "user"
129+
assert trace[0]["content"] == multi_modal_content
130+
131+
110132
@patch("data_designer.engine.column_generators.generators.base.logger", autospec=True)
111133
def test_log_pre_generation(mock_logger: Mock) -> None:
112134
generator, mock_resource_provider, _, mock_model_config, mock_inference_params, _, _ = (

0 commit comments

Comments
 (0)