Skip to content

Commit 17c0480

Browse files
committed
fix: multi-modal or tool related object will fail the search
Signed-off-by: Sicheng Song <[email protected]>
1 parent 0108c05 commit 17c0480

File tree

10 files changed

+1563
-171
lines changed

10 files changed

+1563
-171
lines changed

common/src/main/java/org/opensearch/ml/common/utils/LlmResultPathGenerator.java

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,33 +58,37 @@ public class LlmResultPathGenerator {
5858
*/
5959
public static String generate(String outputSchemaJson) throws IOException {
6060
if (outputSchemaJson == null || outputSchemaJson.trim().isEmpty()) {
61-
log.warn("Output schema is null or empty, cannot generate llm_result_path");
61+
log.warn("[LLM_RESULT_PATH_GEN] Output schema is null or empty, cannot generate llm_result_path");
6262
return null;
6363
}
6464

65+
log.info("[LLM_RESULT_PATH_GEN] Starting llm_result_path auto-generation (schema size: {} bytes)", outputSchemaJson.length());
66+
6567
try {
6668
JsonNode schemaRoot = MAPPER.readTree(outputSchemaJson);
6769

6870
// Navigate to dataAsMap schema node using hardcoded path; if not found, search from root
6971
JsonNode searchRoot = navigateToDataAsMapSchema(schemaRoot);
7072
if (searchRoot == null) {
71-
log.debug("No dataAsMap schema found, searching from root");
73+
log.info("[LLM_RESULT_PATH_GEN] No dataAsMap schema found in standard ModelTensorOutput path, searching from schema root");
7274
searchRoot = schemaRoot;
75+
} else {
76+
log.info("[LLM_RESULT_PATH_GEN] Found dataAsMap schema at standard path, searching within dataAsMap structure");
7377
}
7478

7579
// Search for LLM output field with x-llm-output marker
7680
String jsonPath = findLlmTextField(searchRoot, "$");
7781

7882
if (jsonPath == null) {
79-
log.warn("Could not find field with x-llm-output marker in schema");
83+
log.warn("[LLM_RESULT_PATH_GEN] ❌ No field with x-llm-output marker found in schema - will use fallback path");
8084
return null;
8185
}
8286

83-
log.debug("Generated llm_result_path: {}", jsonPath);
87+
log.info("[LLM_RESULT_PATH_GEN] ✅ Successfully generated llm_result_path: {}", jsonPath);
8488
return jsonPath;
8589

8690
} catch (Exception e) {
87-
log.error("Failed to generate llm_result_path from schema", e);
91+
log.error("[LLM_RESULT_PATH_GEN] ❌ Failed to generate llm_result_path from schema", e);
8892
throw new OpenSearchParseException("Schema parsing error: " + e.getMessage(), e);
8993
}
9094
}
@@ -104,9 +108,15 @@ public static String generate(String outputSchemaJson) throws IOException {
104108
*/
105109
private static JsonNode navigateToDataAsMapSchema(JsonNode schemaRoot) {
106110
if (schemaRoot == null || schemaRoot.isMissingNode()) {
111+
log.debug("[LLM_RESULT_PATH_GEN] Schema root is null or missing");
107112
return null;
108113
}
109114

115+
log
116+
.debug(
117+
"[LLM_RESULT_PATH_GEN] Navigating to dataAsMap using rigid path: properties.inference_results.items.properties.output.items.properties.dataAsMap"
118+
);
119+
110120
// Follow the rigid ModelTensorOutput → ModelTensors → ModelTensor structure
111121
JsonNode dataAsMapSchema = schemaRoot
112122
.path("properties")
@@ -118,7 +128,13 @@ private static JsonNode navigateToDataAsMapSchema(JsonNode schemaRoot) {
118128
.path("properties")
119129
.path("dataAsMap");
120130

121-
return dataAsMapSchema.isMissingNode() ? null : dataAsMapSchema;
131+
if (dataAsMapSchema.isMissingNode()) {
132+
log.debug("[LLM_RESULT_PATH_GEN] dataAsMap not found at standard path");
133+
return null;
134+
}
135+
136+
log.debug("[LLM_RESULT_PATH_GEN] Successfully navigated to dataAsMap schema");
137+
return dataAsMapSchema;
122138
}
123139

124140
/**
@@ -148,6 +164,7 @@ private static String findLlmTextFieldWithMarker(JsonNode schemaNode, String cur
148164
// Check if this field has the x-llm-output marker
149165
JsonNode marker = schemaNode.get(LLM_OUTPUT_MARKER);
150166
if (marker != null && marker.isBoolean() && marker.asBoolean()) {
167+
log.info("[LLM_RESULT_PATH_GEN] 🎯 Found x-llm-output marker at path: {}", currentPath);
151168
return currentPath;
152169
}
153170

@@ -166,6 +183,7 @@ private static String findLlmTextFieldWithMarker(JsonNode schemaNode, String cur
166183
JsonNode fieldSchema = field.getValue();
167184

168185
String newPath = currentPath + "." + fieldName;
186+
log.debug("[LLM_RESULT_PATH_GEN] Checking field: {}", newPath);
169187
String result = findLlmTextFieldWithMarker(fieldSchema, newPath);
170188
if (result != null) {
171189
return result;
@@ -179,6 +197,7 @@ private static String findLlmTextFieldWithMarker(JsonNode schemaNode, String cur
179197
JsonNode items = schemaNode.get("items");
180198
if (items != null) {
181199
String newPath = currentPath + "[0]";
200+
log.debug("[LLM_RESULT_PATH_GEN] Navigating into array: {}", newPath);
182201
String result = findLlmTextFieldWithMarker(items, newPath);
183202
if (result != null) {
184203
return result;
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.opensearch.ml.common.utils.message;
7+
8+
import static org.opensearch.common.xcontent.json.JsonXContent.jsonXContent;
9+
10+
import java.io.IOException;
11+
import java.util.HashMap;
12+
import java.util.List;
13+
import java.util.Map;
14+
import java.util.stream.Collectors;
15+
16+
import org.opensearch.core.xcontent.XContentBuilder;
17+
import org.opensearch.ml.common.transport.memorycontainer.memory.MessageInput;
18+
import org.opensearch.ml.common.utils.StringUtils;
19+
20+
import lombok.extern.log4j.Log4j2;
21+
22+
/**
23+
* Message formatter for Claude models (and similar models using system_prompt parameter).
24+
*
25+
* <p>Format characteristics:
26+
* <ul>
27+
* <li>System prompt: Placed in "system_prompt" parameter</li>
28+
* <li>Messages: Array of user/assistant messages (NO system role)</li>
29+
* <li>Content: Normalized to have "type" field</li>
30+
* </ul>
31+
*
32+
* <p>Compatible with:
33+
* <ul>
34+
* <li>Claude 3.x (Bedrock, Anthropic API)</li>
35+
* <li>Claude 4.x (Sonnet, Opus)</li>
36+
* <li>Any model with system_prompt in input schema</li>
37+
* </ul>
38+
*
39+
* <p>Example output:
40+
* <pre>
41+
* {
42+
* "system_prompt": "You are a helpful assistant",
43+
* "messages": "[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Hello\"}]}]"
44+
* }
45+
* </pre>
46+
*/
47+
@Log4j2
48+
public class ClaudeMessageFormatter implements MessageFormatter {
49+
50+
@Override
51+
public Map<String, String> formatRequest(String systemPrompt, List<MessageInput> messages, Map<String, Object> additionalConfig) {
52+
Map<String, String> parameters = new HashMap<>();
53+
54+
// Claude-style: system_prompt as parameter
55+
if (systemPrompt != null && !systemPrompt.isBlank()) {
56+
parameters.put("system_prompt", systemPrompt);
57+
log.debug("System prompt added as parameter");
58+
}
59+
60+
// Build messages array with content processing
61+
try {
62+
String messagesJson = buildMessagesArray(messages, additionalConfig);
63+
parameters.put("messages", messagesJson);
64+
log.debug("Built messages array with {} messages", messages != null ? messages.size() : 0);
65+
} catch (IOException e) {
66+
log.error("Failed to build messages array", e);
67+
throw new RuntimeException("Failed to format Claude request", e);
68+
}
69+
70+
return parameters;
71+
}
72+
73+
@Override
74+
public List<Map<String, Object>> processContent(List<Map<String, Object>> content) {
75+
if (content == null || content.isEmpty()) {
76+
return content;
77+
}
78+
79+
return content.stream().map(this::normalizeContentObject).collect(Collectors.toList());
80+
}
81+
82+
/**
83+
* Normalize a single content object to ensure it has "type" field.
84+
*
85+
* <p>Rules:
86+
* <ul>
87+
* <li>If object has "type" field → return as-is (standard LLM format)</li>
88+
* <li>If object lacks "type" field → wrap as {"type": "text", "text": JSON_STRING}</li>
89+
* </ul>
90+
*
91+
* @param obj Content object to normalize
92+
* @return Normalized content object with "type" field
93+
*/
94+
private Map<String, Object> normalizeContentObject(Map<String, Object> obj) {
95+
if (obj == null || obj.isEmpty()) {
96+
return obj;
97+
}
98+
99+
// Already has type field → standard format
100+
if (obj.containsKey("type")) {
101+
return obj;
102+
}
103+
104+
// No type field → user-defined object, wrap as text
105+
Map<String, Object> wrapped = new HashMap<>();
106+
wrapped.put("type", "text");
107+
String jsonText = StringUtils.toJson(obj);
108+
wrapped.put("text", jsonText);
109+
return wrapped;
110+
}
111+
112+
/**
113+
* Build messages JSON array from MessageInput list.
114+
*
115+
* <p>Includes:
116+
* <ul>
117+
* <li>Optional system_prompt_message from config (added first)</li>
118+
* <li>User/assistant messages with processed content</li>
119+
* <li>Optional user_prompt_message from config (added last)</li>
120+
* </ul>
121+
*
122+
* @param messages List of messages to include
123+
* @param additionalConfig Optional config with extra messages
124+
* @return JSON string representing messages array
125+
* @throws IOException if JSON building fails
126+
*/
127+
private String buildMessagesArray(List<MessageInput> messages, Map<String, Object> additionalConfig) throws IOException {
128+
XContentBuilder builder = jsonXContent.contentBuilder();
129+
builder.startArray();
130+
131+
// Optional system_prompt_message from config
132+
if (additionalConfig != null && additionalConfig.containsKey("system_prompt_message")) {
133+
Object systemPromptMsg = additionalConfig.get("system_prompt_message");
134+
if (systemPromptMsg instanceof Map) {
135+
@SuppressWarnings("unchecked")
136+
Map<String, Object> msgMap = (Map<String, Object>) systemPromptMsg;
137+
builder.map(msgMap);
138+
}
139+
}
140+
141+
// User messages (with content processing)
142+
if (messages != null) {
143+
for (MessageInput message : messages) {
144+
builder.startObject();
145+
146+
if (message.getRole() != null) {
147+
builder.field("role", message.getRole());
148+
}
149+
150+
// Process content to ensure type fields
151+
if (message.getContent() != null) {
152+
List<Map<String, Object>> processedContent = processContent(message.getContent());
153+
builder.field("content", processedContent);
154+
}
155+
156+
builder.endObject();
157+
}
158+
}
159+
160+
// Optional user_prompt_message from config
161+
if (additionalConfig != null && additionalConfig.containsKey("user_prompt_message")) {
162+
Object userPromptMsg = additionalConfig.get("user_prompt_message");
163+
if (userPromptMsg instanceof Map) {
164+
@SuppressWarnings("unchecked")
165+
Map<String, Object> msgMap = (Map<String, Object>) userPromptMsg;
166+
builder.map(msgMap);
167+
}
168+
}
169+
170+
builder.endArray();
171+
return builder.toString();
172+
}
173+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.opensearch.ml.common.utils.message;
7+
8+
import java.util.List;
9+
import java.util.Map;
10+
11+
import org.opensearch.ml.common.transport.memorycontainer.memory.MessageInput;
12+
13+
/**
14+
* Strategy interface for formatting LLM requests based on model requirements.
15+
*
16+
* <p>Each formatter implementation handles:
17+
* <ul>
18+
* <li>System prompt placement (parameter vs message)</li>
19+
* <li>Content object normalization (type field enforcement)</li>
20+
* <li>Message array construction</li>
21+
* </ul>
22+
*
23+
* <p>Implementations:
24+
* <ul>
25+
* <li>{@link ClaudeMessageFormatter}: Uses system_prompt parameter (Claude, Bedrock models)</li>
26+
* <li>{@link OpenAIMessageFormatter}: Injects system message in array (OpenAI, GPT models)</li>
27+
* </ul>
28+
*
29+
* <p>Usage:
30+
* <pre>
31+
* MessageFormatter formatter = MessageFormatterFactory.getFormatterForModel(modelId, cache);
32+
* Map&lt;String, String&gt; params = formatter.formatRequest(systemPrompt, messages, config);
33+
* </pre>
34+
*/
35+
public interface MessageFormatter {
36+
37+
/**
38+
* Format a complete LLM request with proper system prompt placement.
39+
*
40+
* <p>This method handles:
41+
* <ul>
42+
* <li>Placing system prompt in correct location (parameter or message)</li>
43+
* <li>Building messages array with content processing</li>
44+
* <li>Incorporating additional config messages (system_prompt_message, user_prompt_message)</li>
45+
* </ul>
46+
*
47+
* @param systemPrompt The system prompt to inject (may be null or blank)
48+
* @param messages User/assistant messages to include in the request
49+
* @param additionalConfig Optional configuration containing:
50+
* <ul>
51+
* <li>system_prompt_message: Additional system-level message</li>
52+
* <li>user_prompt_message: Additional user message</li>
53+
* </ul>
54+
* @return Map of request parameters ready for MLInput, typically containing:
55+
* <ul>
56+
* <li>"messages": JSON array of messages</li>
57+
* <li>"system_prompt": System prompt (Claude-style only)</li>
58+
* </ul>
59+
* @throws RuntimeException if message array building fails
60+
*/
61+
Map<String, String> formatRequest(String systemPrompt, List<MessageInput> messages, Map<String, Object> additionalConfig);
62+
63+
/**
64+
* Process message content objects to ensure LLM compatibility.
65+
*
66+
* <p>Content processing rules:
67+
* <ul>
68+
* <li>Objects WITH "type" field → keep as-is (standard LLM format like
69+
* {"type": "text", "text": "..."} or {"type": "image_url", "image_url": {...}})</li>
70+
* <li>Objects WITHOUT "type" field → wrap as {"type": "text", "text": JSON_STRING}
71+
* where JSON_STRING is the serialized user-defined object</li>
72+
* </ul>
73+
*
74+
* <p>This ensures that user-defined content objects are properly formatted
75+
* for LLM consumption without breaking standard multimodal content.
76+
*
77+
* @param content List of content objects from message (may be null or empty)
78+
* @return Processed content list with all objects having "type" field
79+
*/
80+
List<Map<String, Object>> processContent(List<Map<String, Object>> content);
81+
}

0 commit comments

Comments
 (0)