fix: 统一适配 DeepSeek 工具与思考历史回传

- 结构化持久化 assistant/tool 历史,恢复真实消息链回放

- 为 DeepSeek 显式开启 thinking 协议配置,并补齐 public-api 与工具英文名兜底

- 增加聊天历史回放、tool 名称与请求参数解析相关测试
This commit is contained in:
2026-05-11 21:24:20 +08:00
parent c1590b0d8a
commit e27834ee0c
18 changed files with 1441 additions and 42 deletions

View File

@@ -0,0 +1,142 @@
package tech.easyflow.ai.easyagents.memory;
import com.easyagents.core.message.AiMessage;
import com.easyagents.core.message.Message;
import com.easyagents.core.message.ToolCall;
import com.easyagents.core.message.ToolMessage;
import com.easyagents.core.model.client.OpenAIChatMessageSerializer;
import com.easyagents.llm.deepseek.DeepseekConfig;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.core.runtime.ChatAssistantAccumulator;
import tech.easyflow.core.runtime.ChatRuntimeMessage;
import java.util.List;
/**
* {@link RuntimeChatMemory} 测试。
*/
public class RuntimeChatMemoryTest {
/**
* 应当从结构化 payload 中恢复完整的 assistant/tool 消息链。
*/
@Test
public void shouldRestoreStructuredAssistantAndToolHistory() {
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
accumulator.appendReasoning("思考-1");
accumulator.appendToolCall("call-1", "kb_search", "{\"query\":\"java\"}");
accumulator.appendToolResult("call-1", "kb_search", "{\"hits\":1}");
accumulator.appendReasoning("思考-2");
accumulator.appendContent("最终回答");
ChatRuntimeMessage runtimeMessage = new ChatRuntimeMessage();
runtimeMessage.setRole("assistant");
runtimeMessage.setContentText("最终回答");
runtimeMessage.setContentPayload(accumulator.buildPayload("最终回答"));
RuntimeChatMemory memory = new RuntimeChatMemory("c1", List.of(runtimeMessage));
List<Message> messages = memory.getMessages(10);
Assert.assertEquals(3, messages.size());
Assert.assertTrue(messages.get(0) instanceof AiMessage);
Assert.assertTrue(messages.get(1) instanceof ToolMessage);
Assert.assertTrue(messages.get(2) instanceof AiMessage);
AiMessage toolAssistant = (AiMessage) messages.get(0);
Assert.assertEquals("思考-1", toolAssistant.getReasoningContent());
Assert.assertEquals(1, toolAssistant.getToolCalls().size());
Assert.assertEquals("call-1", toolAssistant.getToolCalls().get(0).getId());
Assert.assertEquals("{\"query\":\"java\"}", toolAssistant.getToolCalls().get(0).getArguments());
ToolMessage toolMessage = (ToolMessage) messages.get(1);
Assert.assertEquals("call-1", toolMessage.getToolCallId());
Assert.assertEquals("{\"hits\":1}", toolMessage.getContent());
AiMessage finalAssistant = (AiMessage) messages.get(2);
Assert.assertEquals("最终回答", finalAssistant.getTextContent());
Assert.assertEquals("思考-2", finalAssistant.getReasoningContent());
}
/**
* 旧结构历史应保持纯文本兼容。
*/
@Test
public void shouldFallbackToPlainAssistantMessageForLegacyPayload() {
ChatRuntimeMessage runtimeMessage = new ChatRuntimeMessage();
runtimeMessage.setRole("assistant");
runtimeMessage.setContentText("旧版回答");
RuntimeChatMemory memory = new RuntimeChatMemory("c2", List.of(runtimeMessage));
List<Message> messages = memory.getMessages(10);
Assert.assertEquals(1, messages.size());
Assert.assertTrue(messages.get(0) instanceof AiMessage);
Assert.assertEquals("旧版回答", ((AiMessage) messages.get(0)).getTextContent());
}
/**
* 内部聊天结构化历史回放后DeepSeek 序列化应继续带上 reasoning_content 与 tool 链。
*/
@Test
public void shouldSerializeRestoredRuntimeHistoryForDeepseekFollowUp() {
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
accumulator.appendReasoning("先思考");
accumulator.appendToolCall("call-1", "kb_search", "{\"query\":\"java\"}");
accumulator.appendToolResult("call-1", "kb_search", "{\"hits\":1}");
ChatRuntimeMessage runtimeMessage = new ChatRuntimeMessage();
runtimeMessage.setRole("assistant");
runtimeMessage.setContentPayload(accumulator.buildPayload(null));
RuntimeChatMemory memory = new RuntimeChatMemory("c3", List.of(runtimeMessage));
List<Message> restoredMessages = memory.getMessages(10);
DeepseekConfig config = new DeepseekConfig();
config.setSupportThinking(Boolean.TRUE);
config.setThinkingProtocol("deepseek");
config.setNeedReasoningContentForToolMessage(Boolean.TRUE);
List<java.util.Map<String, Object>> serialized = new OpenAIChatMessageSerializer()
.serializeMessages(restoredMessages, config);
Assert.assertEquals(2, serialized.size());
Assert.assertEquals("assistant", serialized.get(0).get("role"));
Assert.assertEquals("先思考", serialized.get(0).get("reasoning_content"));
Assert.assertTrue(serialized.get(0).containsKey("tool_calls"));
Assert.assertEquals("tool", serialized.get(1).get("role"));
Assert.assertEquals("call-1", serialized.get(1).get("tool_call_id"));
}
/**
* 连续多轮 tool-only assistant 回放后仍应保持独立顺序。
*/
@Test
public void shouldRestoreSeparatedToolOnlyAssistantSegments() {
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
accumulator.appendToolCall("call-1", "tool_one", "{\"step\":1}");
accumulator.appendToolResult("call-1", "tool_one", "{\"ok\":1}");
accumulator.appendToolCall("call-2", "tool_two", "{\"step\":2}");
accumulator.appendToolResult("call-2", "tool_two", "{\"ok\":2}");
ChatRuntimeMessage runtimeMessage = new ChatRuntimeMessage();
runtimeMessage.setRole("assistant");
runtimeMessage.setContentPayload(accumulator.buildPayload(null));
RuntimeChatMemory memory = new RuntimeChatMemory("c4", List.of(runtimeMessage));
List<Message> messages = memory.getMessages(10);
Assert.assertEquals(4, messages.size());
Assert.assertTrue(messages.get(0) instanceof AiMessage);
Assert.assertTrue(messages.get(1) instanceof ToolMessage);
Assert.assertTrue(messages.get(2) instanceof AiMessage);
Assert.assertTrue(messages.get(3) instanceof ToolMessage);
AiMessage firstAssistant = (AiMessage) messages.get(0);
AiMessage secondAssistant = (AiMessage) messages.get(2);
Assert.assertEquals(1, firstAssistant.getToolCalls().size());
Assert.assertEquals("call-1", firstAssistant.getToolCalls().get(0).getId());
Assert.assertEquals(1, secondAssistant.getToolCalls().size());
Assert.assertEquals("call-2", secondAssistant.getToolCalls().get(0).getId());
}
}

View File

@@ -0,0 +1,79 @@
package tech.easyflow.ai.easyagents.tool;
import org.junit.Assert;
import org.junit.Test;
import java.math.BigInteger;
/**
* {@link ChatToolNameHelper} 单元测试。
*
* @author Codex
* @since 2026-05-11
*/
public class ChatToolNameHelperTest {
/**
* 启用英文名时应优先返回合法英文名称。
*/
@Test
public void resolveToolNameShouldPreferValidEnglishName() {
String name = ChatToolNameHelper.resolveToolName(
true,
"knowledge_search",
"知识库检索",
"knowledge",
BigInteger.valueOf(101)
);
Assert.assertEquals("knowledge_search", name);
}
/**
* 英文名缺失时应回退为稳定安全名称。
*/
@Test
public void resolveToolNameShouldFallbackWhenEnglishNameMissing() {
String name = ChatToolNameHelper.resolveToolName(
true,
null,
"知识库检索",
"knowledge",
BigInteger.valueOf(101)
);
Assert.assertEquals("knowledge_101", name);
}
/**
* 英文名不满足协议约束时应回退为稳定安全名称。
*/
@Test
public void resolveToolNameShouldFallbackWhenEnglishNameInvalid() {
String name = ChatToolNameHelper.resolveToolName(
true,
"工作流-A",
"工作流检索",
"workflow",
BigInteger.valueOf(202)
);
Assert.assertEquals("workflow_202", name);
}
/**
* 未启用英文名时应保留展示名称。
*/
@Test
public void resolveToolNameShouldKeepDisplayNameWhenEnglishNameDisabled() {
String name = ChatToolNameHelper.resolveToolName(
false,
"workflow_search",
"工作流检索",
"workflow",
BigInteger.valueOf(202)
);
Assert.assertEquals("工作流检索", name);
}
}

View File

@@ -0,0 +1,179 @@
package tech.easyflow.ai.easyagents.tool;
import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.chain.repository.ChainDefinitionRepository;
import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository;
import com.easyagents.flow.core.chain.repository.InMemoryNodeStateRepository;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.context.ApplicationContext;
import tech.easyflow.ai.entity.Workflow;
import java.lang.reflect.Field;
import java.lang.reflect.Proxy;
import java.math.BigInteger;
/**
* {@link WorkflowTool} 单元测试。
*
* @author Codex
* @since 2026-05-11
*/
public class WorkflowToolTest {
/**
* 启用英文名且字段合法时,应直接使用英文名。
*
* @throws Exception 反射注入异常
*/
@Test
public void shouldUseValidEnglishNameWhenEnabled() throws Exception {
ApplicationContext previousContext = getStaticField("applicationContext");
Object previousBeanFactory = getStaticField("beanFactory");
try {
setStaticField("beanFactory", null);
setStaticField("applicationContext", mockApplicationContext(buildChainExecutor("workflow-1")));
WorkflowTool tool = new WorkflowTool(buildWorkflow(101, "workflow_search", "工作流检索"), true, "workflow-1");
Assert.assertEquals("workflow_search", tool.getName());
} finally {
setStaticField("applicationContext", previousContext);
setStaticField("beanFactory", previousBeanFactory);
}
}
/**
* 启用英文名但字段缺失时,应回退为稳定安全名称。
*
* @throws Exception 反射注入异常
*/
@Test
public void shouldFallbackToSafeNameWhenEnglishNameMissing() throws Exception {
ApplicationContext previousContext = getStaticField("applicationContext");
Object previousBeanFactory = getStaticField("beanFactory");
try {
setStaticField("beanFactory", null);
setStaticField("applicationContext", mockApplicationContext(buildChainExecutor("workflow-2")));
WorkflowTool tool = new WorkflowTool(buildWorkflow(202, null, "工作流检索"), true, "workflow-2");
Assert.assertEquals("workflow_202", tool.getName());
} finally {
setStaticField("applicationContext", previousContext);
setStaticField("beanFactory", previousBeanFactory);
}
}
/**
* 启用英文名但字段非法时,应回退为稳定安全名称。
*
* @throws Exception 反射注入异常
*/
@Test
public void shouldFallbackToSafeNameWhenEnglishNameInvalid() throws Exception {
ApplicationContext previousContext = getStaticField("applicationContext");
Object previousBeanFactory = getStaticField("beanFactory");
try {
setStaticField("beanFactory", null);
setStaticField("applicationContext", mockApplicationContext(buildChainExecutor("workflow-3")));
WorkflowTool tool = new WorkflowTool(buildWorkflow(303, "工作流-A", "工作流检索"), true, "workflow-3");
Assert.assertEquals("workflow_303", tool.getName());
} finally {
setStaticField("applicationContext", previousContext);
setStaticField("beanFactory", previousBeanFactory);
}
}
private Workflow buildWorkflow(long id, String englishName, String title) {
Workflow workflow = new Workflow();
workflow.setId(BigInteger.valueOf(id));
workflow.setEnglishName(englishName);
workflow.setTitle(title);
workflow.setDescription("desc-" + id);
return workflow;
}
private ChainExecutor buildChainExecutor(String definitionId) {
ChainDefinitionRepository definitionRepository = id -> {
ChainDefinition definition = new ChainDefinition();
definition.setId(definitionId);
return definition;
};
return new ChainExecutor(
definitionRepository,
new InMemoryChainStateRepository(),
new InMemoryNodeStateRepository()
);
}
private ApplicationContext mockApplicationContext(ChainExecutor chainExecutor) {
return (ApplicationContext) Proxy.newProxyInstance(
ApplicationContext.class.getClassLoader(),
new Class[]{ApplicationContext.class},
(proxy, method, args) -> {
if ("getBean".equals(method.getName()) && args != null && args.length == 1 && args[0] == ChainExecutor.class) {
return chainExecutor;
}
if ("equals".equals(method.getName())) {
return proxy == args[0];
}
if ("hashCode".equals(method.getName())) {
return System.identityHashCode(proxy);
}
return defaultValue(method.getReturnType());
}
);
}
private static <T> T getStaticField(String name) throws Exception {
Field field = Class.forName("tech.easyflow.common.util.SpringContextUtil").getDeclaredField(name);
field.setAccessible(true);
@SuppressWarnings("unchecked")
T value = (T) field.get(null);
return value;
}
private static void setStaticField(String name, Object value) throws Exception {
Field field = Class.forName("tech.easyflow.common.util.SpringContextUtil").getDeclaredField(name);
field.setAccessible(true);
field.set(null, value);
}
private Object defaultValue(Class<?> returnType) {
if (returnType == null || returnType == Void.TYPE) {
return null;
}
if (!returnType.isPrimitive()) {
return null;
}
if (returnType == Boolean.TYPE) {
return false;
}
if (returnType == Character.TYPE) {
return '\0';
}
if (returnType == Byte.TYPE) {
return (byte) 0;
}
if (returnType == Short.TYPE) {
return (short) 0;
}
if (returnType == Integer.TYPE) {
return 0;
}
if (returnType == Long.TYPE) {
return 0L;
}
if (returnType == Float.TYPE) {
return 0F;
}
if (returnType == Double.TYPE) {
return 0D;
}
return null;
}
}

View File

@@ -0,0 +1,120 @@
package tech.easyflow.ai.entity;
import com.easyagents.core.message.AiMessage;
import com.easyagents.core.message.Message;
import com.easyagents.core.message.ToolMessage;
import com.easyagents.core.model.client.OpenAIChatMessageSerializer;
import com.easyagents.llm.deepseek.DeepseekConfig;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.easyagents.memory.PublicBotMessageMemory;
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
import java.util.List;
import java.util.Map;
/**
* {@link ChatRequestParams} 测试。
*/
public class ChatRequestParamsTest {
/**
* 应兼容解析 assistant 的 reasoning 与 OpenAI 风格 tool_calls。
*/
@Test
public void shouldParseAssistantReasoningAndToolCalls() {
ChatRequestParams params = new ChatRequestParams();
params.setMessagesFromJson(List.of(
Map.of(
"role", "assistant",
"content", "",
"reasoning_content", "先思考",
"tool_calls", List.of(
Map.of(
"id", "call-1",
"type", "function",
"function", Map.of(
"name", "kb_search",
"arguments", "{\"query\":\"test\"}"
)
)
)
)
));
Message message = params.getMessages().get(0);
Assert.assertTrue(message instanceof AiMessage);
AiMessage aiMessage = (AiMessage) message;
Assert.assertEquals("先思考", aiMessage.getReasoningContent());
Assert.assertEquals(1, aiMessage.getToolCalls().size());
Assert.assertEquals("kb_search", aiMessage.getToolCalls().get(0).getName());
Assert.assertEquals("{\"query\":\"test\"}", aiMessage.getToolCalls().get(0).getArguments());
}
/**
* 应兼容解析 tool 消息的下划线字段。
*/
@Test
public void shouldParseToolMessageWithSnakeCaseToolCallId() {
ChatRequestParams params = new ChatRequestParams();
params.setMessagesFromJson(List.of(
Map.of(
"role", "tool",
"content", "{\"hits\":1}",
"tool_call_id", "call-2"
)
));
Message message = params.getMessages().get(0);
Assert.assertTrue(message instanceof ToolMessage);
ToolMessage toolMessage = (ToolMessage) message;
Assert.assertEquals("call-2", toolMessage.getToolCallId());
Assert.assertEquals("{\"hits\":1}", toolMessage.getContent());
}
/**
* public-api 多轮 assistant/tool 历史透传后DeepSeek 后续轮次序列化不应丢字段。
*/
@Test
public void shouldKeepPublicApiAssistantAndToolHistoryForDeepseekFollowUp() {
ChatRequestParams params = new ChatRequestParams();
params.setMessagesFromJson(List.of(
Map.of(
"role", "assistant",
"content", "",
"reasoning_content", "先思考",
"tool_calls", List.of(
Map.of(
"id", "call-1",
"type", "function",
"function", Map.of(
"name", "kb_search",
"arguments", "{\"query\":\"test\"}"
)
)
)
),
Map.of(
"role", "tool",
"content", "{\"hits\":1}",
"tool_call_id", "call-1"
)
));
PublicBotMessageMemory memory = new PublicBotMessageMemory(new ChatSseEmitter(), params.getMessages());
DeepseekConfig config = new DeepseekConfig();
config.setSupportThinking(Boolean.TRUE);
config.setThinkingProtocol("deepseek");
config.setNeedReasoningContentForToolMessage(Boolean.TRUE);
List<Map<String, Object>> serialized = new OpenAIChatMessageSerializer()
.serializeMessages(memory.getMessages(10), config);
Assert.assertEquals(2, serialized.size());
Assert.assertEquals("assistant", serialized.get(0).get("role"));
Assert.assertEquals("先思考", serialized.get(0).get("reasoning_content"));
Assert.assertTrue(serialized.get(0).containsKey("tool_calls"));
Assert.assertEquals("tool", serialized.get(1).get("role"));
Assert.assertEquals("call-1", serialized.get(1).get("tool_call_id"));
}
}

View File

@@ -0,0 +1,74 @@
package tech.easyflow.ai.entity;
import com.easyagents.core.message.AiMessage;
import com.easyagents.core.message.ToolCall;
import com.easyagents.core.message.ToolMessage;
import com.easyagents.core.model.client.OpenAIChatMessageSerializer;
import com.easyagents.llm.deepseek.DeepseekChatModel;
import com.easyagents.llm.deepseek.DeepseekConfig;
import org.junit.Assert;
import org.junit.Test;
import java.util.List;
import java.util.Map;
/**
* DeepSeek thinking/tool 配置测试。
*/
public class ModelDeepseekConfigTest {
/**
* DeepSeek 聊天模型应显式开启 thinking/tool 相关协议开关。
*/
@Test
public void shouldEnableDeepseekThinkingProtocolFlags() {
ModelProvider provider = new ModelProvider();
provider.setProviderType("deepseek");
provider.setProviderName("deepseek");
Model model = new Model();
model.setModelProvider(provider);
model.setEndpoint("https://api.deepseek.com");
model.setApiKey("sk-test");
model.setModelName("deepseek-chat");
model.setRequestPath("/chat/completions");
DeepseekChatModel chatModel = (DeepseekChatModel) model.toChatModel();
DeepseekConfig config = chatModel.getConfig();
Assert.assertTrue(config.isSupportThinking());
Assert.assertEquals("deepseek", config.getThinkingProtocol());
Assert.assertTrue(config.isNeedReasoningContentForToolMessage());
}
/**
* DeepSeek 在 tool call assistant 历史上应序列化 reasoning_content。
*/
@Test
public void shouldSerializeReasoningContentForToolAssistantMessage() {
DeepseekConfig config = new DeepseekConfig();
config.setNeedReasoningContentForToolMessage(Boolean.TRUE);
config.setThinkingProtocol("deepseek");
config.setSupportThinking(Boolean.TRUE);
AiMessage assistant = new AiMessage(null);
assistant.setReasoningContent("先推理");
assistant.setToolCalls(List.of(new ToolCall("call-1", "kb_search", "{\"query\":\"java\"}")));
ToolMessage toolMessage = new ToolMessage();
toolMessage.setToolCallId("call-1");
toolMessage.setContent("{\"hits\":1}");
List<Map<String, Object>> serialized = new OpenAIChatMessageSerializer().serializeMessages(
List.of(assistant, toolMessage),
config
);
Assert.assertEquals(2, serialized.size());
Assert.assertEquals("assistant", serialized.get(0).get("role"));
Assert.assertEquals("先推理", serialized.get(0).get("reasoning_content"));
Assert.assertTrue(serialized.get(0).containsKey("tool_calls"));
Assert.assertEquals("tool", serialized.get(1).get("role"));
Assert.assertEquals("call-1", serialized.get(1).get("tool_call_id"));
}
}

View File

@@ -0,0 +1,73 @@
package tech.easyflow.core.runtime;
import org.junit.Assert;
import org.junit.Test;
import java.util.List;
import java.util.Map;
/**
* {@link ChatAssistantAccumulator} 测试。
*/
public class ChatAssistantAccumulatorTest {
/**
* 应同时保留展示链和可回放的结构化消息链。
*/
@Test
@SuppressWarnings("unchecked")
public void shouldBuildStructuredPayloadWithDisplayChains() {
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
accumulator.appendReasoning("思考-1");
accumulator.appendToolCall("call-1", "kb_search", "{\"query\":\"java\"}");
accumulator.appendToolResult("call-1", "kb_search", "{\"hits\":1}");
accumulator.appendReasoning("思考-2");
accumulator.appendContent("最终回答");
Map<String, Object> payload = accumulator.buildPayload("最终回答");
List<Map<String, Object>> messageChain = (List<Map<String, Object>>) payload.get("messageChain");
List<Map<String, Object>> chains = (List<Map<String, Object>>) payload.get("chains");
Assert.assertEquals(3, messageChain.size());
Assert.assertEquals("assistant", messageChain.get(0).get("role"));
Assert.assertEquals("思考-1", messageChain.get(0).get("reasoningContent"));
Assert.assertEquals("tool", messageChain.get(1).get("role"));
Assert.assertEquals("assistant", messageChain.get(2).get("role"));
Assert.assertEquals("最终回答", messageChain.get(2).get("content"));
Assert.assertEquals("思考-2", messageChain.get(2).get("reasoningContent"));
Assert.assertFalse(chains.isEmpty());
Assert.assertEquals("思考-1思考-2", chains.get(0).get("reasoning_content"));
Assert.assertEquals("{\"query\":\"java\"}", chains.get(1).get("arguments"));
Assert.assertEquals("{\"hits\":1}", chains.get(1).get("result"));
}
/**
* 连续多轮 tool-only assistant 不应被错误合并到同一条 assistant 历史。
*/
@Test
@SuppressWarnings("unchecked")
public void shouldKeepToolOnlyAssistantSegmentsSeparatedAcrossRecursiveRounds() {
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
accumulator.appendToolCall("call-1", "tool_one", "{\"step\":1}");
accumulator.appendToolResult("call-1", "tool_one", "{\"ok\":1}");
accumulator.appendToolCall("call-2", "tool_two", "{\"step\":2}");
accumulator.appendToolResult("call-2", "tool_two", "{\"ok\":2}");
Map<String, Object> payload = accumulator.buildPayload(null);
List<Map<String, Object>> messageChain = (List<Map<String, Object>>) payload.get("messageChain");
Assert.assertEquals(4, messageChain.size());
Assert.assertEquals("assistant", messageChain.get(0).get("role"));
Assert.assertEquals("tool", messageChain.get(1).get("role"));
Assert.assertEquals("assistant", messageChain.get(2).get("role"));
Assert.assertEquals("tool", messageChain.get(3).get("role"));
List<Map<String, Object>> firstToolCalls = (List<Map<String, Object>>) messageChain.get(0).get("toolCalls");
List<Map<String, Object>> secondToolCalls = (List<Map<String, Object>>) messageChain.get(2).get("toolCalls");
Assert.assertEquals(1, firstToolCalls.size());
Assert.assertEquals("call-1", firstToolCalls.get(0).get("id"));
Assert.assertEquals(1, secondToolCalls.size());
Assert.assertEquals("call-2", secondToolCalls.get(0).get("id"));
}
}