|
|
|
@ -1,17 +1,30 @@
|
|
|
|
|
package com.supervision.rasa.service.impl;
|
|
|
|
|
|
|
|
|
|
import cn.hutool.core.collection.CollUtil;
|
|
|
|
|
import cn.hutool.core.collection.CollectionUtil;
|
|
|
|
|
import cn.hutool.core.collection.ListUtil;
|
|
|
|
|
import cn.hutool.core.io.FileUtil;
|
|
|
|
|
import cn.hutool.core.lang.Pair;
|
|
|
|
|
import cn.hutool.core.util.StrUtil;
|
|
|
|
|
import cn.hutool.json.JSONUtil;
|
|
|
|
|
import com.supervision.exception.BusinessException;
|
|
|
|
|
import com.supervision.model.AskTemplateQuestionLibrary;
|
|
|
|
|
import com.supervision.model.ConfigAncillaryItem;
|
|
|
|
|
import com.supervision.model.ConfigPhysicalTool;
|
|
|
|
|
import com.supervision.model.RasaModelInfo;
|
|
|
|
|
import com.supervision.rasa.config.ThreadPoolExecutorConfig;
|
|
|
|
|
import com.supervision.rasa.constant.RasaConstant;
|
|
|
|
|
import com.supervision.rasa.pojo.dto.*;
|
|
|
|
|
import com.supervision.rasa.pojo.vo.RasaCmdArgumentVo;
|
|
|
|
|
import com.supervision.rasa.service.RasaCmdService;
|
|
|
|
|
import com.supervision.rasa.service.Text2vecService;
|
|
|
|
|
import com.supervision.rasa.util.PortUtil;
|
|
|
|
|
import com.supervision.service.AskTemplateQuestionLibraryService;
|
|
|
|
|
import com.supervision.service.ConfigAncillaryItemService;
|
|
|
|
|
import com.supervision.service.ConfigPhysicalToolService;
|
|
|
|
|
import com.supervision.service.RasaModeService;
|
|
|
|
|
import freemarker.template.Configuration;
|
|
|
|
|
import freemarker.template.Template;
|
|
|
|
|
import lombok.RequiredArgsConstructor;
|
|
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
|
|
@ -23,6 +36,7 @@ import java.io.*;
|
|
|
|
|
import java.util.*;
|
|
|
|
|
import java.util.concurrent.*;
|
|
|
|
|
import java.util.function.Predicate;
|
|
|
|
|
import java.util.stream.Collectors;
|
|
|
|
|
|
|
|
|
|
@Service
|
|
|
|
|
@Slf4j
|
|
|
|
@ -46,26 +60,36 @@ public class RasaCmdServiceImpl implements RasaCmdService {
|
|
|
|
|
@Value("${rasa.shell-env:/bin/bash}")
|
|
|
|
|
private String shellEnv;
|
|
|
|
|
|
|
|
|
|
@Value("${rasa.data-path:/home/rasa/model_resource/}")
|
|
|
|
|
private String rasaFilePath;
|
|
|
|
|
|
|
|
|
|
private final RasaModeService rasaModeService;
|
|
|
|
|
|
|
|
|
|
private final ConfigPhysicalToolService configPhysicalToolService;
|
|
|
|
|
|
|
|
|
|
private final ConfigAncillaryItemService configAncillaryItemService;
|
|
|
|
|
|
|
|
|
|
private final AskTemplateQuestionLibraryService askTemplateQuestionLibraryService;
|
|
|
|
|
|
|
|
|
|
private final Text2vecService text2vecService;
|
|
|
|
|
private final ConcurrentHashMap<String,String> shellPathCache = new ConcurrentHashMap<>();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@Override
|
|
|
|
|
@Transactional
|
|
|
|
|
public String trainExec(RasaCmdArgumentVo argument) throws ExecutionException, InterruptedException, TimeoutException {
|
|
|
|
|
log.info("trainExec:start train rasa model ....argument:{}", JSONUtil.toJsonStr(argument));
|
|
|
|
|
|
|
|
|
|
argument.setFixedModelNameIfAbsent();
|
|
|
|
|
|
|
|
|
|
// /rasa/v3_jiazhuangxian/domain.yml domain的路径,应该是从zip文件中加压出来的文件的路径后面拼上/domain.yml
|
|
|
|
|
String domain = replaceDuplicateSeparator(String.join(File.separator,dataPath,argument.getModelId(),"domain.yml"));
|
|
|
|
|
String domain = replaceDuplicateSeparator(String.join(File.separator,dataPath,"domain.yml"));
|
|
|
|
|
|
|
|
|
|
// /rasa/v3_jiazhuangxian/ yml文件的路径,应该是从zip文件中加压出来的文件的路径,在配置文件中配置
|
|
|
|
|
String localDataPath = replaceDuplicateSeparator(String.join(File.separator,dataPath,argument.getModelId()));
|
|
|
|
|
String localDataPath = replaceDuplicateSeparator(String.join(File.separator,dataPath));
|
|
|
|
|
|
|
|
|
|
// /rasa/models 生成出来的模型的存放路径,也写在配置文件里面
|
|
|
|
|
String localModelsPath = replaceDuplicateSeparator(String.join(File.separator,modelsPath,argument.getModelId()));
|
|
|
|
|
String localModelsPath = replaceDuplicateSeparator(String.join(File.separator,modelsPath));
|
|
|
|
|
|
|
|
|
|
List<String> cmds = ListUtil.toList(shellEnv, getShellPath(RasaConstant.TRAIN_SHELL),config,localDataPath,domain,localModelsPath);
|
|
|
|
|
|
|
|
|
@ -84,8 +108,10 @@ public class RasaCmdServiceImpl implements RasaCmdService {
|
|
|
|
|
cmds.set(1,null);
|
|
|
|
|
rasaModelInfo.setTrainCmd(cmds);
|
|
|
|
|
rasaModelInfo.setTrainLog(outMessageString);
|
|
|
|
|
rasaModelInfo.setModelId("1");
|
|
|
|
|
rasaModeService.saveOrUpdateByModelId(rasaModelInfo);
|
|
|
|
|
|
|
|
|
|
log.info("trainExec:end train rasa model ....");
|
|
|
|
|
return outMessageString;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@ -94,7 +120,7 @@ public class RasaCmdServiceImpl implements RasaCmdService {
|
|
|
|
|
@Override
|
|
|
|
|
public String runExec(RasaCmdArgumentVo argument) throws ExecutionException, InterruptedException, TimeoutException {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
log.info("runExec:start runExec rasa model ....args:{}",JSONUtil.toJsonStr(argument));
|
|
|
|
|
// 1. 查找可用端口
|
|
|
|
|
int port = PortUtil.findUnusedPort(5050, 100000,rasaModeService.listActivePort());
|
|
|
|
|
log.info("runExec findUnusedPort is : {}",port);
|
|
|
|
@ -104,7 +130,7 @@ public class RasaCmdServiceImpl implements RasaCmdService {
|
|
|
|
|
// aaa1111.tar.gz这个,前面的文件名应该是--fixed-model-name指定的,.tar.gz是文件后缀,代码拼接
|
|
|
|
|
|
|
|
|
|
String fixedModePath;
|
|
|
|
|
String modeParentPath = replaceDuplicateSeparator(String.join(File.separator, modelsPath, argument.getModelId()));
|
|
|
|
|
String modeParentPath = replaceDuplicateSeparator(String.join(File.separator, modelsPath));
|
|
|
|
|
if (StrUtil.isEmpty(argument.getFixedModelName())){
|
|
|
|
|
fixedModePath = listLastFilePath(modeParentPath, f -> f.getName().matches("-?\\d+(\\.\\d+)?.tar.gz"));
|
|
|
|
|
}else {
|
|
|
|
@ -142,6 +168,7 @@ public class RasaCmdServiceImpl implements RasaCmdService {
|
|
|
|
|
rasaModelInfo.setRunLog(outMessageString);
|
|
|
|
|
rasaModeService.saveOrUpdateByModelId(rasaModelInfo);
|
|
|
|
|
|
|
|
|
|
log.info("runExec:runExec end ....");
|
|
|
|
|
return outMessageString;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@ -176,56 +203,63 @@ public class RasaCmdServiceImpl implements RasaCmdService {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@Override
|
|
|
|
|
public boolean deployRasa() throws Exception {
|
|
|
|
|
|
|
|
|
|
private boolean trainIsSuccess(List<String> messageList){
|
|
|
|
|
// 1.生成rasa模型语料文件
|
|
|
|
|
Map<String, QuestionAnswerDTO> questionAnswerDTOMap = generateRasaYml(String.join(File.separator, rasaFilePath));
|
|
|
|
|
|
|
|
|
|
return containKey(messageList,RasaConstant.TRAN_SUCCESS_MESSAGE);
|
|
|
|
|
}
|
|
|
|
|
// 2.训练模型
|
|
|
|
|
trainExec(new RasaCmdArgumentVo());
|
|
|
|
|
|
|
|
|
|
//3.运行模型
|
|
|
|
|
RasaCmdArgumentVo rasaCmdArgumentVo = new RasaCmdArgumentVo();
|
|
|
|
|
rasaCmdArgumentVo.setModelId("1");
|
|
|
|
|
runExec(rasaCmdArgumentVo);
|
|
|
|
|
|
|
|
|
|
private boolean runIsSuccess(List<String> messageList){
|
|
|
|
|
// 更新text2vec数据信息
|
|
|
|
|
List<Text2vecDataVo> text2vecDataVoList = questionAnswerDTOMap.entrySet().stream()
|
|
|
|
|
.flatMap(entry -> entry.getValue().getQuestionList().stream()
|
|
|
|
|
.map(question -> new Text2vecDataVo(entry.getKey(), question))).collect(Collectors.toList());
|
|
|
|
|
text2vecService.updateDataset(text2vecDataVoList);
|
|
|
|
|
|
|
|
|
|
return containKey(messageList,RasaConstant.RUN_SUCCESS_MESSAGE);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private boolean containKey(List<String> messageList,String keyWord){
|
|
|
|
|
@Override
|
|
|
|
|
public Map<String, QuestionAnswerDTO> generateRasaYml(String path) {
|
|
|
|
|
|
|
|
|
|
if (CollectionUtil.isEmpty(messageList)){
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (StrUtil.isEmpty(keyWord)){
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return messageList.stream().anyMatch(s->StrUtil.isNotEmpty(s) && s.contains(keyWord));
|
|
|
|
|
}
|
|
|
|
|
log.info("generateRasaYml:start generateRasaYml ....");
|
|
|
|
|
|
|
|
|
|
private String replaceDuplicateSeparator(String path){
|
|
|
|
|
// 默认问答MAP
|
|
|
|
|
List<RuleYmlTemplate.Rule> ruleList = new ArrayList<>();
|
|
|
|
|
|
|
|
|
|
if (StrUtil.isEmpty(path)){
|
|
|
|
|
return path;
|
|
|
|
|
}
|
|
|
|
|
Map<String, File> ymalFileMap = new HashMap<>();
|
|
|
|
|
Map<String, QuestionAnswerDTO> intentCodeAndIdMap = getIntentCodeAndIdMap();
|
|
|
|
|
|
|
|
|
|
return path.replace(File.separator + File.separator, File.separator);
|
|
|
|
|
}
|
|
|
|
|
// 开始生成各种yaml文件
|
|
|
|
|
Pair<String, File> nulFilePair = generateNlu(intentCodeAndIdMap);
|
|
|
|
|
ymalFileMap.put(nulFilePair.getKey(),nulFilePair.getValue());
|
|
|
|
|
|
|
|
|
|
Pair<String, File> domainFilePair = generateDomain(intentCodeAndIdMap, ruleList);
|
|
|
|
|
ymalFileMap.put(domainFilePair.getKey(),domainFilePair.getValue());
|
|
|
|
|
|
|
|
|
|
Pair<String, File> ruleFilePair = generateRule(ruleList);
|
|
|
|
|
ymalFileMap.put(ruleFilePair.getKey(),ruleFilePair.getValue());
|
|
|
|
|
|
|
|
|
|
private String listLastFilePath(String path, FileFilter filter){
|
|
|
|
|
File file = listLastFile(path, filter);
|
|
|
|
|
if (null == file){
|
|
|
|
|
return null;
|
|
|
|
|
// 把文件复制到指定位置
|
|
|
|
|
for (Map.Entry<String, File> fileEntry : ymalFileMap.entrySet()) {
|
|
|
|
|
try {
|
|
|
|
|
FileUtil.copy(fileEntry.getValue(), new File(StrUtil.join(File.separator, path,fileEntry.getKey())), true);
|
|
|
|
|
}finally {
|
|
|
|
|
FileUtil.del(fileEntry.getValue());
|
|
|
|
|
}
|
|
|
|
|
return file.getPath();
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
private File listLastFile(String path,FileFilter filter){
|
|
|
|
|
File file = new File(path);
|
|
|
|
|
File[] files = file.listFiles(filter);
|
|
|
|
|
if (null == files){
|
|
|
|
|
return null;
|
|
|
|
|
log.info("generateRasaYml:end generateRasaYml ....");
|
|
|
|
|
return intentCodeAndIdMap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Arrays.stream(files).max(Comparator.comparing(File::getName)).orElse(null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public String getShellPath(String shell){
|
|
|
|
@ -266,6 +300,172 @@ public class RasaCmdServiceImpl implements RasaCmdService {
|
|
|
|
|
throw new RuntimeException(e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
private Pair<String, File> generateNlu(Map<String, QuestionAnswerDTO> intentCodeAndIdMap) {
|
|
|
|
|
// 首先生成根据意图查找到nlu文件
|
|
|
|
|
List<NluYmlTemplate.Nlu> nluList = intentCodeAndIdMap.entrySet()
|
|
|
|
|
.stream().map(entry ->
|
|
|
|
|
new NluYmlTemplate.Nlu(entry.getKey(), entry.getValue().getQuestionList()))
|
|
|
|
|
.collect(Collectors.toList());
|
|
|
|
|
|
|
|
|
|
NluYmlTemplate nluYmlTemplate = new NluYmlTemplate();
|
|
|
|
|
nluYmlTemplate.setNlu(nluList);
|
|
|
|
|
// 生成后生成yml文件
|
|
|
|
|
return createYmlFile(NluYmlTemplate.class, "nlu.ftl", nluYmlTemplate, "nlu.yml");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Map<String, QuestionAnswerDTO> getIntentCodeAndIdMap() {
|
|
|
|
|
Map<String, QuestionAnswerDTO> intentCodeAndIdMap = new HashMap<>();
|
|
|
|
|
// 默认意图
|
|
|
|
|
List<AskTemplateQuestionLibrary> askTemplateQuestionLibraryList = askTemplateQuestionLibraryService.lambdaQuery().list();
|
|
|
|
|
// 生成默认意图的nlu
|
|
|
|
|
for (AskTemplateQuestionLibrary questionLibrary : askTemplateQuestionLibraryList) {
|
|
|
|
|
// 开始生成
|
|
|
|
|
// 拼接格式:code_id(防止重复)
|
|
|
|
|
String intentCode = questionLibrary.getCode() + "_" + questionLibrary.getId();
|
|
|
|
|
intentCodeAndIdMap.put(intentCode, new QuestionAnswerDTO(questionLibrary.getQuestion(), CollUtil.newArrayList( questionLibrary.getId()), questionLibrary.getDescription()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 这里处理呼出的问题(code和问题不能为空)
|
|
|
|
|
List<ConfigPhysicalTool> physicalToolList = configPhysicalToolService.lambdaQuery()
|
|
|
|
|
.isNotNull(ConfigPhysicalTool::getCode)
|
|
|
|
|
.isNotNull(ConfigPhysicalTool::getCallOutQuestion).list();
|
|
|
|
|
|
|
|
|
|
for (ConfigPhysicalTool tool : physicalToolList) {
|
|
|
|
|
// 把呼出的问题全部加进去
|
|
|
|
|
String toolIntent = "tool_" + tool.getCode();
|
|
|
|
|
// answer格式为:---tool---工具ID
|
|
|
|
|
intentCodeAndIdMap.put(toolIntent,
|
|
|
|
|
new QuestionAnswerDTO(tool.getCallOutQuestion(),
|
|
|
|
|
CollUtil.newArrayList("tool_" + tool.getId()), "tool-" + tool.getToolName()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 生成呼出的辅助检查
|
|
|
|
|
List<ConfigAncillaryItem> ancillaryItemList = configAncillaryItemService.lambdaQuery()
|
|
|
|
|
.isNotNull(ConfigAncillaryItem::getCode)
|
|
|
|
|
.isNotNull(ConfigAncillaryItem::getCallOutQuestion).list();
|
|
|
|
|
|
|
|
|
|
for (ConfigAncillaryItem ancillary : ancillaryItemList) {
|
|
|
|
|
// 把辅助问诊的问题全部加进去
|
|
|
|
|
String itemIntent = "ancillary_" + ancillary.getCode();
|
|
|
|
|
// answer格式为:---ancillary---工具ID
|
|
|
|
|
intentCodeAndIdMap.put(itemIntent,
|
|
|
|
|
new QuestionAnswerDTO(ancillary.getCallOutQuestion(),
|
|
|
|
|
CollUtil.newArrayList("ancillary_" + ancillary.getId()), "呼出-ancillary-" + ancillary.getItemName()));
|
|
|
|
|
}
|
|
|
|
|
return intentCodeAndIdMap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public Pair<String, File> generateDomain(Map<String, QuestionAnswerDTO> questionCodeAndIdMap, List<RuleYmlTemplate.Rule> ruleList) {
|
|
|
|
|
LinkedHashMap<String, List<String>> responses = new LinkedHashMap<>();
|
|
|
|
|
for (Map.Entry<String, QuestionAnswerDTO> entry : questionCodeAndIdMap.entrySet()) {
|
|
|
|
|
String intentCode = entry.getKey();
|
|
|
|
|
QuestionAnswerDTO value = entry.getValue();
|
|
|
|
|
String utter = "utter_" + intentCode;
|
|
|
|
|
responses.put(utter, CollUtil.newArrayList(value.getAnswerList()));
|
|
|
|
|
ruleList.add(new RuleYmlTemplate.Rule(value.getDesc(), intentCode, utter));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DomainYmlTemplate domainYmlTemplate = new DomainYmlTemplate();
|
|
|
|
|
// 意图
|
|
|
|
|
List<String> intentList = new ArrayList<>(questionCodeAndIdMap.keySet());
|
|
|
|
|
domainYmlTemplate.setIntents(intentList);
|
|
|
|
|
// 回复
|
|
|
|
|
domainYmlTemplate.setResponses(responses);
|
|
|
|
|
// action
|
|
|
|
|
List<String> actionList = new ArrayList<>(responses.keySet());
|
|
|
|
|
domainYmlTemplate.setActions(actionList);
|
|
|
|
|
// 生成yml文件
|
|
|
|
|
return createYmlFile(DomainYmlTemplate.class, "domain.ftl", domainYmlTemplate, "domain.yml");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 生成rule
|
|
|
|
|
*/
|
|
|
|
|
public Pair<String, File> generateRule(List<RuleYmlTemplate.Rule> ruleList) {
|
|
|
|
|
RuleYmlTemplate ruleYmlTemplate = new RuleYmlTemplate();
|
|
|
|
|
ruleYmlTemplate.setRules(ruleList);
|
|
|
|
|
// 生成yml文件
|
|
|
|
|
return createYmlFile(RuleYmlTemplate.class, "rules.ftl", ruleYmlTemplate, "rules.yml");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Pair<String,File> createYmlFile(Class<?> clazz, String ftlName, Object data, String ymlName) {
|
|
|
|
|
try {
|
|
|
|
|
// 这个版本和maven依赖的版本一致
|
|
|
|
|
Configuration configuration = new Configuration(Configuration.VERSION_2_3_31);
|
|
|
|
|
configuration.setClassForTemplateLoading(clazz, "/templates"); // 模板文件的所在目录
|
|
|
|
|
// 获取模板
|
|
|
|
|
Template template = configuration.getTemplate(ftlName);
|
|
|
|
|
File tempFile = FileUtil.createTempFile(".yml", true);
|
|
|
|
|
// 创建输出文件
|
|
|
|
|
try (PrintWriter out = new PrintWriter(tempFile);) {
|
|
|
|
|
// 填充并生成输出
|
|
|
|
|
template.process(data, out);
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
log.error("文件生成失败");
|
|
|
|
|
}
|
|
|
|
|
return Pair.of(ymlName,tempFile);
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
log.error("导出模板失败", e);
|
|
|
|
|
throw new RuntimeException("文件生成失败", e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private boolean trainIsSuccess(List<String> messageList){
|
|
|
|
|
|
|
|
|
|
return containKey(messageList,RasaConstant.TRAN_SUCCESS_MESSAGE);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private boolean runIsSuccess(List<String> messageList){
|
|
|
|
|
|
|
|
|
|
return containKey(messageList,RasaConstant.RUN_SUCCESS_MESSAGE);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private boolean containKey(List<String> messageList,String keyWord){
|
|
|
|
|
|
|
|
|
|
if (CollectionUtil.isEmpty(messageList)){
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (StrUtil.isEmpty(keyWord)){
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return messageList.stream().anyMatch(s->StrUtil.isNotEmpty(s) && s.contains(keyWord));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String replaceDuplicateSeparator(String path){
|
|
|
|
|
|
|
|
|
|
if (StrUtil.isEmpty(path)){
|
|
|
|
|
return path;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return path.replace(File.separator + File.separator, File.separator);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private String listLastFilePath(String path, FileFilter filter){
|
|
|
|
|
File file = listLastFile(path, filter);
|
|
|
|
|
if (null == file){
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return file.getPath();
|
|
|
|
|
}
|
|
|
|
|
private File listLastFile(String path,FileFilter filter){
|
|
|
|
|
File file = new File(path);
|
|
|
|
|
File[] files = file.listFiles(filter);
|
|
|
|
|
if (null == files){
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Arrays.stream(files).max(Comparator.comparing(File::getName)).orElse(null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|