会话总结工作流接入、前后端处理

This commit is contained in:
2026-01-01 15:12:29 +08:00
parent 4e373e6d2c
commit eb15706ccc
22 changed files with 1738 additions and 43 deletions

View File

@@ -86,7 +86,7 @@ public class AiInit {
}
}
String summaryAgentApiKey = sysConfigService.getStringConfig("dify.workcase.agent.summary");
String summaryAgentApiKey = sysConfigService.getStringConfig("dify.workcase.workflow.summary");
TbAgent summaryAgent = new TbAgent();
summaryAgent.setIsOuter(true);
summaryAgent.setName("工单总结");

View File

@@ -21,6 +21,9 @@ import org.xyzh.api.workcase.dto.TbChatRoomMessageDTO;
import org.xyzh.api.workcase.dto.TbCustomerServiceDTO;
import org.xyzh.api.workcase.dto.TbVideoMeetingDTO;
import org.xyzh.api.workcase.dto.TbWordCloudDTO;
import org.xyzh.api.workcase.dto.ChatRoomSummaryRequest;
import org.xyzh.api.workcase.dto.ChatRoomSummaryResponse;
import org.xyzh.api.workcase.service.AgentService;
import org.xyzh.api.workcase.service.ChatRoomService;
import org.xyzh.api.workcase.service.WorkcaseChatService;
import org.xyzh.api.workcase.vo.ChatMemberVO;
@@ -68,6 +71,9 @@ public class WorkcaseChatController {
@Autowired
private ChatRoomService chatRoomService;
@Autowired
private AgentService agentService;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@@ -243,6 +249,31 @@ public class WorkcaseChatController {
return chatRoomService.deleteMessage(messageId);
}
@Operation(summary = "获取聊天室最新总结")
@PreAuthorize("hasAuthority('workcase:room:view')")
@GetMapping("/room/{roomId}/summary")
public ResultDomain<ChatRoomSummaryResponse> getLatestSummary(@PathVariable(value = "roomId") String roomId) {
return agentService.getLatestSummary(roomId);
}
@Operation(summary = "生成聊天室对话总结")
@PreAuthorize("hasAuthority('workcase:room:view')")
@PostMapping("/room/{roomId}/summary")
public ResultDomain<ChatRoomSummaryResponse> summaryChatRoom(
@PathVariable(value = "roomId") String roomId,
@RequestBody(required = false) ChatRoomSummaryRequest request) {
// 如果请求体为空,创建一个默认的请求对象
if (request == null) {
request = new ChatRoomSummaryRequest();
}
// 设置聊天室ID
request.setRoomId(roomId);
// 调用服务层进行总结
return agentService.summaryChatRoom(request);
}
// ========================= 客服人员管理 =========================
@Operation(summary = "添加客服人员")

View File

@@ -0,0 +1,60 @@
package org.xyzh.workcase.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.xyzh.api.workcase.dto.TbChatRoomSummaryDTO;
import org.xyzh.common.core.page.PageParam;
/**
* @description 聊天室总结数据访问层
* @filename TbChatRoomSummaryMapper.java
* @author system
* @copyright xyzh
* @since 2026-01-01
*/
@Mapper
public interface TbChatRoomSummaryMapper {
/**
* 插入聊天室总结
*/
int insertChatRoomSummary(TbChatRoomSummaryDTO summary);
/**
* 更新聊天室总结只更新非null字段
*/
int updateChatRoomSummary(TbChatRoomSummaryDTO summary);
/**
* 根据ID查询聊天室总结
*/
TbChatRoomSummaryDTO selectChatRoomSummaryById(@Param("summaryId") String summaryId);
/**
* 根据聊天室ID查询最新一条总结
*/
TbChatRoomSummaryDTO selectLatestSummaryByRoomId(@Param("roomId") String roomId);
/**
* 查询聊天室总结列表
*/
List<TbChatRoomSummaryDTO> selectChatRoomSummaryList(@Param("filter") TbChatRoomSummaryDTO filter);
/**
* 分页查询聊天室总结
*/
List<TbChatRoomSummaryDTO> selectChatRoomSummaryPage(@Param("filter") TbChatRoomSummaryDTO filter, @Param("pageParam") PageParam pageParam);
/**
* 统计聊天室总结数量
*/
long countChatRoomSummaries(@Param("filter") TbChatRoomSummaryDTO filter);
/**
* 删除聊天室总结(逻辑删除)
*/
int deleteChatRoomSummary(@Param("summaryId") String summaryId);
}

View File

@@ -0,0 +1,355 @@
package org.xyzh.workcase.service;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.dubbo.config.annotation.DubboReference;
import org.apache.dubbo.config.annotation.DubboService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import org.xyzh.api.ai.dto.ChatPrepareData;
import org.xyzh.api.ai.dto.TbAgent;
import org.xyzh.api.ai.service.AgentChatService;
import org.xyzh.api.system.service.SysConfigService;
import org.xyzh.api.workcase.dto.ChatRoomSummaryRequest;
import org.xyzh.api.workcase.dto.ChatRoomSummaryResponse;
import org.xyzh.api.workcase.dto.TbChatRoomMessageDTO;
import org.xyzh.api.workcase.dto.TbChatRoomSummaryDTO;
import org.xyzh.api.workcase.dto.TbWordCloudDTO;
import org.xyzh.api.workcase.service.AgentService;
import org.xyzh.api.workcase.vo.ChatRoomMessageVO;
import org.xyzh.common.core.domain.ResultDomain;
import org.xyzh.common.utils.id.IdUtil;
import org.xyzh.workcase.mapper.TbChatMessageMapper;
import org.xyzh.workcase.mapper.TbChatRoomSummaryMapper;
import org.xyzh.workcase.mapper.TbWordCloudMapper;
/**
* @description 智能体服务实现类提供AI相关的业务功能
* @filename AgentServiceImpl.java
* @author system
* @copyright xyzh
* @since 2026-01-01
*/
@DubboService(version = "1.0.0", group = "workcase", timeout = 30000, retries = 0)
public class AgentServiceImpl implements AgentService {
private static final Logger logger = LoggerFactory.getLogger(AgentServiceImpl.class);
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
// 系统配置键名
private static final String CONFIG_KEY_SUMMARY_API = "dify.workcase.workflow.summary";
@Autowired
private TbChatMessageMapper chatMessageMapper;
@Autowired
private TbWordCloudMapper wordCloudMapper;
@Autowired
private TbChatRoomSummaryMapper chatRoomSummaryMapper;
@DubboReference(version = "1.0.0", group = "system", timeout = 30000, retries = 0)
private SysConfigService sysConfigService;
@DubboReference(version = "1.0.0", group = "ai", timeout = 30000, retries = 0)
private org.xyzh.api.ai.service.AgentService aiAgentService;
@DubboReference(version = "1.0.0", group = "ai", timeout = 60000, retries = 0)
private AgentChatService agentChatService;
@Override
public ResultDomain<ChatRoomSummaryResponse> summaryChatRoom(ChatRoomSummaryRequest request) {
try {
logger.info("开始总结聊天室: roomId={}", request.getRoomId());
// 1. 从系统配置获取API Key
String apiKey = sysConfigService.getStringConfig(CONFIG_KEY_SUMMARY_API);
if (apiKey == null || apiKey.isEmpty()) {
logger.error("未配置聊天室总结工作流的API Key: {}", CONFIG_KEY_SUMMARY_API);
return ResultDomain.failure("系统未配置聊天室总结功能");
}
// 2. 根据API Key查询对应的Agent
TbAgent agentFilter = new TbAgent();
agentFilter.setApiKey(apiKey);
ResultDomain<TbAgent> agentResult = aiAgentService.getAgentList(agentFilter);
if (!agentResult.getSuccess() || agentResult.getDataList() == null || agentResult.getDataList().isEmpty()) {
logger.error("未找到API Key对应的智能体: {}", apiKey);
return ResultDomain.failure("未找到对应的智能体配置");
}
TbAgent agent = agentResult.getDataList().get(0);
logger.info("找到智能体: agentId={}, name={}", agent.getAgentId(), agent.getName());
// 3. 获取聊天室的所有消息
TbChatRoomMessageDTO filter = new TbChatRoomMessageDTO();
filter.setRoomId(request.getRoomId());
List<ChatRoomMessageVO> messages = chatMessageMapper.selectChatMessageList(filter);
if (messages == null || messages.isEmpty()) {
return ResultDomain.failure("聊天室没有消息");
}
// 4. 过滤消息(根据请求参数)
List<Map<String, Object>> filteredMessages = new ArrayList<>();
for (ChatRoomMessageVO message : messages) {
String messageType = message.getMessageType();
// 根据请求参数决定是否包含系统消息和会议消息
boolean shouldInclude = true;
if ("system".equals(messageType) && !Boolean.TRUE.equals(request.getIncludeSystemMessages())) {
shouldInclude = false;
}
if ("meeting".equals(messageType) && !Boolean.TRUE.equals(request.getIncludeMeetingMessages())) {
shouldInclude = false;
}
if (shouldInclude) {
Map<String, Object> msgMap = new HashMap<>();
msgMap.put("senderType", message.getSenderType()); // guest/ai/agent
msgMap.put("content", message.getContent());
msgMap.put("send_time", DATE_FORMAT.format(message.getSendTime()));
filteredMessages.add(msgMap);
}
}
if (filteredMessages.isEmpty()) {
return ResultDomain.failure("聊天室没有有效的对话消息");
}
logger.info("聊天室 {} 共有 {} 条有效消息", request.getRoomId(), filteredMessages.size());
// 5. 将消息列表转换为JSON字符串
String chatMessagesJson = JSON.toJSONString(filteredMessages);
// 6. 准备调用工作流的参数
ChatPrepareData prepareData = new ChatPrepareData();
prepareData.setAgentId(agent.getAgentId());
prepareData.setQuery("总结聊天内容");
prepareData.setUserId("system_summary");
prepareData.setUserType(true); // 作为员工身份调用
prepareData.setAppType("workflow"); // 设置为workflow类型
// 7. 设置工作流的输入参数
Map<String, Object> inputsMap = new HashMap<>();
inputsMap.put("chatMessages", chatMessagesJson); // 工作流期望的输入参数名
prepareData.setInputsMap(inputsMap);
logger.info("准备工作流会话,输入参数: chatMessages长度={}", chatMessagesJson.length());
// 8. 调用准备会话
ResultDomain<String> prepareResult = agentChatService.prepareChatMessageSession(prepareData);
if (!prepareResult.getSuccess()) {
logger.error("准备工作流会话失败: {}", prepareResult.getMessage());
return ResultDomain.failure("准备会话失败: " + prepareResult.getMessage());
}
String sessionId = prepareResult.getData();
logger.info("工作流会话准备成功: sessionId={}", sessionId);
// 9. 调用工作流执行,获取完整结果
ResultDomain<String> workflowResult = agentChatService.runWorkflowWithSession(sessionId);
if (!workflowResult.getSuccess()) {
logger.error("工作流执行失败: {}", workflowResult.getMessage());
return ResultDomain.failure("总结失败: " + workflowResult.getMessage());
}
String outputsJson = workflowResult.getData();
logger.debug("工作流返回结果: {}", outputsJson);
// 10. 解析工作流返回的outputsJSON格式
JSONObject outputsObject;
try {
outputsObject = JSON.parseObject(outputsJson);
} catch (Exception e) {
logger.error("解析工作流输出失败: {}", outputsJson, e);
return ResultDomain.failure("工作流输出格式错误");
}
// 11. 从outputs中获取text字段工作流的输出节点
String text = outputsObject.getString("text");
if (text == null || text.isEmpty()) {
logger.error("工作流输出中没有text字段: {}", outputsJson);
return ResultDomain.failure("工作流输出缺少text字段");
}
// 12. 解析text中的JSON结果
JSONObject resultJson;
try {
resultJson = JSON.parseObject(text);
} catch (Exception e) {
logger.error("解析工作流text字段失败: {}", text, e);
return ResultDomain.failure("工作流返回结果格式错误");
}
// 13. 构建响应对象
ChatRoomSummaryResponse response = new ChatRoomSummaryResponse();
response.setRoomId(request.getRoomId());
response.setQuestion(resultJson.getString("question"));
// 安全获取needs数组
List<String> needs = resultJson.getList("needs", String.class);
response.setNeeds(needs != null ? needs : new ArrayList<>());
response.setAnswer(resultJson.getString("answer"));
// 安全获取workcloud数组
List<String> workcloud = resultJson.getList("workcloud", String.class);
response.setWorkcloud(workcloud != null ? workcloud : new ArrayList<>());
response.setSummaryTime(DATE_FORMAT.format(new java.util.Date()));
response.setMessageCount(filteredMessages.size());
// 14. 保存词云数据到数据库
if (workcloud != null && !workcloud.isEmpty()) {
saveWordCloudData(request.getRoomId(), workcloud);
}
// 15. 保存总结数据到数据库
saveChatRoomSummary(request.getRoomId(), resultJson, filteredMessages.size());
logger.info("聊天室总结完成: roomId={}, question={}, wordcloud数量={}",
request.getRoomId(), response.getQuestion(), workcloud != null ? workcloud.size() : 0);
return ResultDomain.success("总结成功", response);
} catch (Exception e) {
logger.error("总结聊天室异常: roomId={}", request.getRoomId(), e);
return ResultDomain.failure("总结失败: " + e.getMessage());
}
}
/**
* 保存词云数据到数据库
* @param roomId 聊天室ID
* @param wordList 词云关键词列表
*/
private void saveWordCloudData(String roomId, List<String> wordList) {
try {
String today = new SimpleDateFormat("yyyy-MM-dd").format(new java.util.Date());
for (String word : wordList) {
if (word == null || word.trim().isEmpty()) {
continue;
}
// 查询是否已存在该词条(同一天、同一分类)
TbWordCloudDTO filter = new TbWordCloudDTO();
filter.setWord(word.trim());
filter.setCategory("chatroom_summary"); // 分类:聊天室总结
filter.setStatDate(today);
TbWordCloudDTO existing = wordCloudMapper.selectWordCloudOne(filter);
if (existing != null) {
// 已存在,增加词频
wordCloudMapper.incrementFrequency(existing.getWordId(), 1);
logger.debug("词云词频递增: word={}, wordId={}", word, existing.getWordId());
} else {
// 不存在,插入新词条
TbWordCloudDTO newWord = new TbWordCloudDTO();
newWord.setWordId(IdUtil.getSnowflakeId());
newWord.setOptsn(IdUtil.getOptsn());
newWord.setWord(word.trim());
newWord.setFrequency("1");
newWord.setCategory("chatroom_summary");
newWord.setStatDate(today);
wordCloudMapper.insertWordCloud(newWord);
logger.debug("插入新词云: word={}, wordId={}", word, newWord.getWordId());
}
}
logger.info("词云数据保存完成: roomId={}, 词条数量={}", roomId, wordList.size());
} catch (Exception e) {
logger.error("保存词云数据失败: roomId={}", roomId, e);
// 词云保存失败不影响总结流程,仅记录日志
}
}
/**
* 保存聊天室总结数据到数据库
* @param roomId 聊天室ID
* @param resultJson 工作流返回的JSON结果
* @param messageCount 参与总结的消息数量
*/
private void saveChatRoomSummary(String roomId, JSONObject resultJson, int messageCount) {
try {
TbChatRoomSummaryDTO summary = new TbChatRoomSummaryDTO();
summary.setSummaryId(IdUtil.getSnowflakeId());
summary.setOptsn(IdUtil.getOptsn());
summary.setRoomId(roomId);
summary.setQuestion(resultJson.getString("question"));
// 获取needs数组并转换为String[]
List<String> needsList = resultJson.getList("needs", String.class);
if (needsList != null && !needsList.isEmpty()) {
summary.setNeeds(needsList);
} else {
summary.setNeeds(new ArrayList<>());
}
summary.setAnswer(resultJson.getString("answer"));
// 获取workcloud数组并转换为String[]
List<String> workcloudList = resultJson.getList("workcloud", String.class);
if (workcloudList != null && !workcloudList.isEmpty()) {
summary.setWorkcloud(workcloudList);
} else {
summary.setWorkcloud(new ArrayList<>());
}
summary.setMessageCount(messageCount);
summary.setSummaryTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new java.util.Date()));
summary.setCreator("system");
chatRoomSummaryMapper.insertChatRoomSummary(summary);
logger.info("聊天室总结数据保存成功: roomId={}, summaryId={}", roomId, summary.getSummaryId());
} catch (Exception e) {
logger.error("保存聊天室总结数据失败: roomId={}", roomId, e);
// 总结数据保存失败不影响主流程,仅记录日志
}
}
@Override
public ResultDomain<ChatRoomSummaryResponse> getLatestSummary(String roomId) {
try {
logger.info("查询聊天室最新总结: roomId={}", roomId);
// 查询最新的总结记录
TbChatRoomSummaryDTO summary = chatRoomSummaryMapper.selectLatestSummaryByRoomId(roomId);
if (summary == null) {
logger.info("未找到聊天室总结: roomId={}", roomId);
return ResultDomain.failure("暂无总结数据");
}
// 构建响应对象
ChatRoomSummaryResponse response = new ChatRoomSummaryResponse();
response.setRoomId(summary.getRoomId());
response.setQuestion(summary.getQuestion());
response.setNeeds(summary.getNeeds());
response.setAnswer(summary.getAnswer());
response.setWorkcloud(summary.getWorkcloud());
response.setSummaryTime(summary.getSummaryTime());
response.setMessageCount(summary.getMessageCount());
logger.info("查询聊天室总结成功: roomId={}, summaryId={}", roomId, summary.getSummaryId());
return ResultDomain.success("查询成功", response);
} catch (Exception e) {
logger.error("查询聊天室总结异常: roomId={}", roomId, e);
return ResultDomain.failure("查询失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,135 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.xyzh.workcase.mapper.TbChatRoomSummaryMapper">
<resultMap id="BaseResultMap" type="org.xyzh.api.workcase.dto.TbChatRoomSummaryDTO">
<id column="summary_id" property="summaryId" jdbcType="VARCHAR"/>
<result column="room_id" property="roomId" jdbcType="VARCHAR"/>
<result column="question" property="question" jdbcType="VARCHAR"/>
<result column="needs" property="needs" jdbcType="ARRAY" typeHandler="org.xyzh.common.jdbc.handler.StringArrayTypeHandler"/>
<result column="answer" property="answer" jdbcType="VARCHAR"/>
<result column="workcloud" property="workcloud" jdbcType="ARRAY" typeHandler="org.xyzh.common.jdbc.handler.StringArrayTypeHandler"/>
<result column="message_count" property="messageCount" jdbcType="INTEGER"/>
<result column="summary_time" property="summaryTime" jdbcType="TIMESTAMP"/>
<result column="optsn" property="optsn" jdbcType="VARCHAR"/>
<result column="creator" property="creator" jdbcType="VARCHAR"/>
<result column="create_time" property="createTime" jdbcType="TIMESTAMP"/>
<result column="update_time" property="updateTime" jdbcType="TIMESTAMP"/>
<result column="delete_time" property="deleteTime" jdbcType="TIMESTAMP"/>
<result column="deleted" property="deleted" jdbcType="BOOLEAN"/>
</resultMap>
<sql id="Base_Column_List">
summary_id, room_id, question, needs, answer, workcloud, message_count, summary_time,
optsn, creator, create_time, update_time, delete_time, deleted
</sql>
<insert id="insertChatRoomSummary" parameterType="org.xyzh.api.workcase.dto.TbChatRoomSummaryDTO">
INSERT INTO workcase.tb_chat_room_summary (
optsn, summary_id, room_id, creator
<if test="question != null">, question</if>
<if test="needs != null">, needs</if>
<if test="answer != null">, answer</if>
<if test="workcloud != null">, workcloud</if>
<if test="messageCount != null">, message_count</if>
<if test="summaryTime != null">, summary_time</if>
) VALUES (
#{optsn}, #{summaryId}, #{roomId}, #{creator}
<if test="question != null">, #{question}</if>
<if test="needs != null">, #{needs, jdbcType=ARRAY, typeHandler=org.xyzh.common.jdbc.handler.StringArrayTypeHandler}</if>
<if test="answer != null">, #{answer}</if>
<if test="workcloud != null">, #{workcloud, jdbcType=ARRAY, typeHandler=org.xyzh.common.jdbc.handler.StringArrayTypeHandler}</if>
<if test="messageCount != null">, #{messageCount}</if>
<if test="summaryTime != null">, #{summaryTime}::timestamptz</if>
)
</insert>
<update id="updateChatRoomSummary" parameterType="org.xyzh.api.workcase.dto.TbChatRoomSummaryDTO">
UPDATE workcase.tb_chat_room_summary
<set>
<if test="question != null">question = #{question},</if>
<if test="needs != null">needs = #{needs, jdbcType=ARRAY, typeHandler=org.xyzh.common.jdbc.handler.StringArrayTypeHandler},</if>
<if test="answer != null">answer = #{answer},</if>
<if test="workcloud != null">workcloud = #{workcloud, jdbcType=ARRAY, typeHandler=org.xyzh.common.jdbc.handler.StringArrayTypeHandler},</if>
<if test="messageCount != null">message_count = #{messageCount},</if>
<if test="summaryTime != null">summary_time = #{summaryTime}::timestamptz,</if>
update_time = now()
</set>
WHERE summary_id = #{summaryId}
</update>
<select id="selectChatRoomSummaryById" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM workcase.tb_chat_room_summary
WHERE summary_id = #{summaryId} AND deleted = false
</select>
<select id="selectLatestSummaryByRoomId" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM workcase.tb_chat_room_summary
WHERE room_id = #{roomId} AND deleted = false
ORDER BY summary_time DESC
LIMIT 1
</select>
<select id="selectChatRoomSummaryList" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM workcase.tb_chat_room_summary
<where>
deleted = false
<if test="filter.summaryId != null and filter.summaryId != ''">
AND summary_id = #{filter.summaryId}
</if>
<if test="filter.roomId != null and filter.roomId != ''">
AND room_id = #{filter.roomId}
</if>
<if test="filter.question != null and filter.question != ''">
AND question LIKE CONCAT('%', #{filter.question}, '%')
</if>
</where>
ORDER BY summary_time DESC
</select>
<select id="selectChatRoomSummaryPage" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM workcase.tb_chat_room_summary
<where>
deleted = false
<if test="filter.summaryId != null and filter.summaryId != ''">
AND summary_id = #{filter.summaryId}
</if>
<if test="filter.roomId != null and filter.roomId != ''">
AND room_id = #{filter.roomId}
</if>
<if test="filter.question != null and filter.question != ''">
AND question LIKE CONCAT('%', #{filter.question}, '%')
</if>
</where>
ORDER BY summary_time DESC
LIMIT #{pageParam.pageSize} OFFSET #{pageParam.offset}
</select>
<select id="countChatRoomSummaries" resultType="long">
SELECT COUNT(*)
FROM workcase.tb_chat_room_summary
<where>
deleted = false
<if test="filter.summaryId != null and filter.summaryId != ''">
AND summary_id = #{filter.summaryId}
</if>
<if test="filter.roomId != null and filter.roomId != ''">
AND room_id = #{filter.roomId}
</if>
<if test="filter.question != null and filter.question != ''">
AND question LIKE CONCAT('%', #{filter.question}, '%')
</if>
</where>
</select>
<update id="deleteChatRoomSummary">
UPDATE workcase.tb_chat_room_summary
SET deleted = true, delete_time = now()
WHERE summary_id = #{summaryId}
</update>
</mapper>