diff --git a/pom.xml b/pom.xml index ee63fc0..c64a5b4 100644 --- a/pom.xml +++ b/pom.xml @@ -363,6 +363,11 @@ spring-boot-starter-websocket + + com.aliyun + bailian20231229 + 2.4.0 + @@ -402,6 +407,13 @@ 17 + + org.apache.maven.plugins + maven-surefire-plugin + + true + + diff --git a/src/main/java/com/gxwebsoft/ai/config/KnowledgeBaseConfig.java b/src/main/java/com/gxwebsoft/ai/config/KnowledgeBaseConfig.java new file mode 100644 index 0000000..d34d1ae --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/config/KnowledgeBaseConfig.java @@ -0,0 +1,16 @@ +package com.gxwebsoft.ai.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "aliyun.knowledge-base") +public class KnowledgeBaseConfig { + + private String accessKeyId; + private String accessKeySecret; + private String workspaceId; + +} \ No newline at end of file diff --git a/src/main/java/com/gxwebsoft/ai/config/TemplateConfig.java b/src/main/java/com/gxwebsoft/ai/config/TemplateConfig.java new file mode 100644 index 0000000..354fc51 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/config/TemplateConfig.java @@ -0,0 +1,12 @@ +package com.gxwebsoft.ai.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "ai.template") +public class TemplateConfig { + private String wordTemplatePath; +} \ No newline at end of file diff --git a/src/main/java/com/gxwebsoft/ai/constants/KnowledgeBaseConstants.java b/src/main/java/com/gxwebsoft/ai/constants/KnowledgeBaseConstants.java new file mode 100644 index 0000000..340b215 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/constants/KnowledgeBaseConstants.java @@ -0,0 +1,19 @@ +package com.gxwebsoft.ai.constants; + +public class KnowledgeBaseConstants { + + public static final String[] KEY_WORDS = { + "", + "审计依据 法律法规 审计业务约定书 经济责任审计管理办法 中共中央办公厅 国务院办公厅 党政主要领导干部审计规定 国家法规 公司管理制度 年度工作目标 党政主要领导干部经济责任审计规定", + "审计目标 经济责任审计目标 资产负债损益真实性 合法性 效益性 经济指标完成情况 重大决策执行 遵守财经法规 国有资产保值增值 经济责任评价 任职期间履职评价 责任界定 业绩评价", + "审计对象 审计范围 被审计领导干部 [职务] [姓名] 任职期间 [开始日期]至[结束日期] 职务任期 重大问题追溯 重要事项延伸 审计时限 下属子公司 代管企业", + "被审计单位基本情况 单位概况 组织机构 人员结构 财务会计政策 合并口径财务数据 资产总额 负债总额 营业收入 利润 内部控制制度 子公司 代管企业 职能部门设置 合并财务报表", + "审计内容 审计重点 贯彻执行经济方针 重大决策执行 发展战略 年度目标完成 法人治理结构 内部控制 财务真实性 风险管控 党风廉政建设 以往审计整改 三重一大经济决策 资产管理 采购管理 债权债务", + "审计风险 证据不充分 评价不客观 内部控制失效 法律法规变化 风险应对策略 审计证据充分性 评价客观性 内部控制审查 法规政策跟踪 重要性水平", + "审计方法 穿行测试 趋势分析 比率分析 访谈法 数据分析 分析性程序 检查 监盘 观察 询问 函证 计算 重新执行", + "审计步骤 时间安排 准备阶段 实施阶段 报告阶段 归档阶段 审计人员安排 资料收集 实质性程序 审计报告编写 交换意见 审计归档 进点会 进度表", + "审计组织实施 审计组人员分工 职责分配 审计工作计划 前期调研 审前培训 实地审计 质量控制 内部培训 沟通协调 分级复核 集体讨论 重大事项汇报 里程碑事件清单 审计工作组 项目负责人 主审" + }; + + +} \ No newline at end of file diff --git a/src/main/java/com/gxwebsoft/ai/controller/AuditReportController.java b/src/main/java/com/gxwebsoft/ai/controller/AuditReportController.java new file mode 100644 index 0000000..ec8a757 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/controller/AuditReportController.java @@ -0,0 +1,144 @@ +package com.gxwebsoft.ai.controller; + +import java.io.FileOutputStream; +import java.io.OutputStream; +import java.util.HashMap; +import java.util.Map; + +import javax.servlet.http.HttpServletResponse; + +import org.apache.poi.openxml4j.util.ZipSecureFile; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.alibaba.fastjson.JSONObject; +import com.gxwebsoft.ai.config.TemplateConfig; +import com.gxwebsoft.ai.dto.AuditReportRequest; +import com.gxwebsoft.ai.dto.KnowledgeBaseRequest; +import com.gxwebsoft.ai.enums.AuditReportEnum; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.system.entity.User; + +import cn.afterturn.easypoi.word.WordExportUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * 审计报告控制器 + * @author GIIT-YC + * + */ +@Tag(name = "审计报告") +@RestController +@RequestMapping("/api/ai/auditReport") +public class AuditReportController extends BaseController { + + @Autowired + private TemplateConfig templateConfig; + + @Autowired + private KnowledgeBaseController knowledgeBaseController; + + private String invok(String query, String knowledge, String history, String suggestion, String userName) { + // 构建请求体 + JSONObject requestBody = new JSONObject(); + JSONObject inputs = new JSONObject(); + inputs.put("query", query); + inputs.put("knowledge", knowledge); + inputs.put("history", history); + inputs.put("suggestion", suggestion); + + requestBody.put("inputs", inputs); + requestBody.put("response_mode", "blocking"); + requestBody.put("user", userName); + + // 发送 POST 请求 + String result = HttpUtil.createPost("http://1.14.159.185:8180/v1/workflows/run") + .header("Authorization", "Bearer app-d7Ok9FECVZG2Ybw9wpg7tGu9") + .header("Content-Type", "application/json") + .body(requestBody.toString()) + .timeout(600000) + .execute() + .body(); + // 解析返回的JSON字符串 + JSONObject jsonResponse = JSONObject.parseObject(result); + // 获取data字段 + JSONObject data = jsonResponse.getJSONObject("data"); + // 获取outputs字段 + JSONObject outputs = data.getJSONObject("outputs"); + // 获取outputs中的result字符串 + String resultStr = outputs.getString("result"); + return resultStr; + } + + /** + * 生成审计报告-单一模块 + */ + @Operation(summary = "生成审计报告-单一模块") + @PostMapping("/generate") + public ApiResult generateAuditReport(@RequestBody AuditReportRequest req) { + final User loginUser = getLoginUser(); + + KnowledgeBaseRequest knowledgeBaseRequest = new KnowledgeBaseRequest(); + knowledgeBaseRequest.setKbId(req.getKbId()); + knowledgeBaseRequest.setFormCommit((req.getFormCommit() > 10) ? req.getFormCommit() / 10 : req.getFormCommit()); + String knowledge = knowledgeBaseController.query(knowledgeBaseRequest).getData().toString(); + + String query = AuditReportEnum.getByCode(req.getFormCommit()).getDesc(); + String ret = this.invok(query, knowledge, req.getHistory(), req.getSuggestion(), loginUser.getUsername()); + + return success(ret); + } + + /** + * 生成并下载审计报告 + */ + @Operation(summary = "生成并下载审计报告") + @PostMapping("/download") + public void downloadAuditReport(@RequestBody AuditReportRequest req, HttpServletResponse response) { + // 保存原始的安全阈值 + double originalMinInflateRatio = ZipSecureFile.getMinInflateRatio(); + + try { + // 降低Zip bomb检测的阈值,解决模板文件的安全检测问题 + ZipSecureFile.setMinInflateRatio(0.001); + + // 准备模板数据 + Map map = new HashMap<>(); + map.put(AuditReportEnum.AUDIT_TITLE.getCode().toString(), req.getFrom0()); + map.put(AuditReportEnum.AUDIT_BASIS.getCode().toString(), req.getFrom1()); + map.put(AuditReportEnum.AUDIT_OBJECTIVE.getCode().toString(), req.getFrom2()); + map.put(AuditReportEnum.AUDIT_SCOPE.getCode().toString(), req.getFrom3()); + map.put(AuditReportEnum.UNIT_OVERVIEW.getCode().toString(), req.getFrom41()); + map.put(AuditReportEnum.ORG_AND_PERSONNEL.getCode().toString(), req.getFrom42()); + map.put(AuditReportEnum.AUDIT_CONTENT_METHODS.getCode().toString(), req.getFrom5()); + + // 使用Easypoi的Word模板功能 + XWPFDocument document = WordExportUtil.exportWord07(templateConfig.getWordTemplatePath(), map); + + // 设置响应头 + response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + response.setHeader("Content-Disposition", "attachment; filename=audit_report.docx"); + + // 输出到响应流 + OutputStream out = response.getOutputStream(); + document.write(out); + out.flush(); + out.close(); + + } catch (Exception e) { + throw new RuntimeException("生成审计报告失败", e); + } finally { + // 恢复原始的安全阈值 + ZipSecureFile.setMinInflateRatio(originalMinInflateRatio); + } + } + +} diff --git a/src/main/java/com/gxwebsoft/ai/controller/KnowledgeBaseController.java b/src/main/java/com/gxwebsoft/ai/controller/KnowledgeBaseController.java new file mode 100644 index 0000000..ed819e4 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/controller/KnowledgeBaseController.java @@ -0,0 +1,59 @@ +package com.gxwebsoft.ai.controller; + +import com.aliyun.bailian20231229.Client; +import com.aliyun.bailian20231229.models.RetrieveResponse; +import com.aliyun.bailian20231229.models.RetrieveResponseBody.RetrieveResponseBodyDataNodes; +import com.gxwebsoft.ai.config.KnowledgeBaseConfig; +import com.gxwebsoft.ai.constants.KnowledgeBaseConstants; +import com.gxwebsoft.ai.factory.KnowledgeBaseClientFactory; +import com.gxwebsoft.ai.util.KnowledgeBaseRetrieve; +import com.gxwebsoft.ai.dto.KnowledgeBaseRequest; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import cn.hutool.core.util.StrUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +@Tag(name = "知识库") +@RestController +@RequestMapping("/api/ai/knowledgeBase") +public class KnowledgeBaseController extends BaseController { + + @Autowired + private KnowledgeBaseConfig config; + + @Autowired + private KnowledgeBaseClientFactory clientFactory; + + @Operation(summary = "查询知识库") + @GetMapping("/query") + public ApiResult query(KnowledgeBaseRequest req) { + Set ret = new LinkedHashSet<>(); + String workspaceId = config.getWorkspaceId(); + List keyWords = Arrays.asList(KnowledgeBaseConstants.KEY_WORDS); + String indexId = req.getKbId(); + String query = StrUtil.isEmpty(req.getQuery()) ? keyWords.get(req.getFormCommit()) : req.getQuery(); + Integer topK = req.getTopK() == null ? 10 : req.getTopK(); + + try { + Client client = clientFactory.createClient(); + RetrieveResponse resp = KnowledgeBaseRetrieve.retrieveIndex(client, workspaceId, indexId, query); + for (RetrieveResponseBodyDataNodes node : resp.getBody().getData().getNodes()) { + ret.add(node.getText()); + if (ret.size() >= topK) { + break; + } + } + } catch (Exception e) { + return fail("查询失败:" + e.getMessage()); + } + return success(ret); + } +} \ No newline at end of file diff --git a/src/main/java/com/gxwebsoft/ai/dto/AuditReportRequest.java b/src/main/java/com/gxwebsoft/ai/dto/AuditReportRequest.java new file mode 100644 index 0000000..1fa78be --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/dto/AuditReportRequest.java @@ -0,0 +1,42 @@ +package com.gxwebsoft.ai.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class AuditReportRequest{ + + @Schema(description = "审计标题") + private String from0; + + @Schema(description = "审计依据") + private String from1; + + @Schema(description = "审计目标") + private String from2; + + @Schema(description = "审计对象和范围") + private String from3; + + @Schema(description = "被审计单位基本情况-单位概况") + private String from41; + + @Schema(description = "被审计单位基本情况-机构和人员相关情况") + private String from42; + + @Schema(description = "审计内容和重点及审计方法") + private String from5; + + @Schema(description = "知识库ID") + private String kbId; + + @Schema(description = "生成模块:AuditReportEnum.code") + private Integer formCommit; + + @Schema(description = "历史内容") + private String history; + + @Schema(description = "修改建议") + private String suggestion; + +} diff --git a/src/main/java/com/gxwebsoft/ai/dto/KnowledgeBaseRequest.java b/src/main/java/com/gxwebsoft/ai/dto/KnowledgeBaseRequest.java new file mode 100644 index 0000000..bee6ee3 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/dto/KnowledgeBaseRequest.java @@ -0,0 +1,20 @@ +package com.gxwebsoft.ai.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class KnowledgeBaseRequest { + + @Schema(description = "知识库ID") + private String kbId; + + @Schema(description = "召回内容") + private String query; + + @Schema(description = "召回模块(1~9)") + private Integer formCommit; + + @Schema(description = "返回TOP切片数量") + private Integer topK; +} diff --git a/src/main/java/com/gxwebsoft/ai/enums/AuditReportEnum.java b/src/main/java/com/gxwebsoft/ai/enums/AuditReportEnum.java new file mode 100644 index 0000000..d66b145 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/enums/AuditReportEnum.java @@ -0,0 +1,45 @@ +package com.gxwebsoft.ai.enums; + +public enum AuditReportEnum { + + AUDIT_TITLE(0, "审计标题"), + AUDIT_BASIS(1, "审计依据"), + AUDIT_OBJECTIVE(2, "审计目标"), + AUDIT_SCOPE(3, "审计对象和范围"), + UNIT_OVERVIEW(41, "被审计单位基本情况-单位概况"), + ORG_AND_PERSONNEL(42, "被审计单位基本情况-机构和人员相关情况"), + AUDIT_CONTENT_METHODS(5, "审计内容和重点及审计方法"); + + private final Integer code; + private final String desc; + + AuditReportEnum(Integer code, String desc) { + this.code = code; + this.desc = desc; + } + + public Integer getCode() { + return code; + } + + public String getDesc() { + return desc; + } + + public static AuditReportEnum getByCode(Integer code) { + for (AuditReportEnum value : values()) { + if (value.code.equals(code)) { + return value; + } + } + return null; + } + + /** + * 根据代码获取描述信息 + */ + public static String getDescByCode(Integer code) { + AuditReportEnum enumValue = getByCode(code); + return enumValue != null ? enumValue.getDesc() : null; + } +} diff --git a/src/main/java/com/gxwebsoft/ai/factory/KnowledgeBaseClientFactory.java b/src/main/java/com/gxwebsoft/ai/factory/KnowledgeBaseClientFactory.java new file mode 100644 index 0000000..5c4beeb --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/factory/KnowledgeBaseClientFactory.java @@ -0,0 +1,23 @@ +package com.gxwebsoft.ai.factory; + +import com.aliyun.bailian20231229.Client; +import com.aliyun.teaopenapi.models.Config; +import com.gxwebsoft.ai.config.KnowledgeBaseConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class KnowledgeBaseClientFactory { + + @Autowired + private KnowledgeBaseConfig config; + + public Client createClient() throws Exception { + com.aliyun.teaopenapi.models.Config authConfig = new com.aliyun.teaopenapi.models.Config() + .setAccessKeyId(config.getAccessKeyId()) + .setAccessKeySecret(config.getAccessKeySecret()); + // 下方接入地址以公有云的公网接入地址为例,可按需更换接入地址。 + authConfig.endpoint = "bailian.cn-beijing.aliyuncs.com"; + return new com.aliyun.bailian20231229.Client(authConfig); + } +} \ No newline at end of file diff --git a/src/main/java/com/gxwebsoft/ai/util/KnowledgeBaseCreate.java b/src/main/java/com/gxwebsoft/ai/util/KnowledgeBaseCreate.java new file mode 100644 index 0000000..7ef388a --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/util/KnowledgeBaseCreate.java @@ -0,0 +1,384 @@ +package com.gxwebsoft.ai.util; + +import com.aliyun.bailian20231229.models.*; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.File; +import java.io.FileInputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.security.MessageDigest; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** + * 创建知识库 + * @author GIIT-YC + * + */ +public class KnowledgeBaseCreate { + + String ALIBABA_CLOUD_ACCESS_KEY_ID = "LTAI5tD5YRKuxWz6Eg7qrM4P"; + String ALIBABA_CLOUD_ACCESS_KEY_SECRET = "bO8TBDXflOwbtSKimPpG8XrJnyzgTk"; + String WORKSPACE_ID = "llm-4pf5auwewoz34zqu"; + + /** + * 检查并提示设置必要的环境变量。 + * + * @return true 如果所有必需的环境变量都已设置,否则 false + */ + public static boolean checkEnvironmentVariables() { + Map requiredVars = new HashMap<>(); + requiredVars.put("ALIBABA_CLOUD_ACCESS_KEY_ID", "阿里云访问密钥ID"); + requiredVars.put("ALIBABA_CLOUD_ACCESS_KEY_SECRET", "阿里云访问密钥密码"); + requiredVars.put("WORKSPACE_ID", "阿里云百炼业务空间ID"); + + List missingVars = new ArrayList<>(); + for (Map.Entry entry : requiredVars.entrySet()) { + String value = System.getenv(entry.getKey()); + if (value == null || value.isEmpty()) { + missingVars.add(entry.getKey()); + System.out.println("错误:请设置 " + entry.getKey() + " 环境变量 (" + entry.getValue() + ")"); + } + } + + return missingVars.isEmpty(); + } + + /** + * 计算文档的MD5值。 + * + * @param filePath 文档本地路径 + * @return 文档的MD5值 + * @throws Exception 如果计算过程中发生错误 + */ + public static String calculateMD5(String filePath) throws Exception { + MessageDigest md = MessageDigest.getInstance("MD5"); + try (FileInputStream fis = new FileInputStream(filePath)) { + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = fis.read(buffer)) != -1) { + md.update(buffer, 0, bytesRead); + } + } + StringBuilder sb = new StringBuilder(); + for (byte b : md.digest()) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } + + /** + * 获取文档大小(以字节为单位)。 + * + * @param filePath 文档本地路径 + * @return 文档大小(以字节为单位) + */ + public static String getFileSize(String filePath) { + File file = new File(filePath); + long fileSize = file.length(); + return String.valueOf(fileSize); + } + + /** + * 初始化客户端(Client)。 + * + * @return 配置好的客户端对象 + */ + public static com.aliyun.bailian20231229.Client createClient() throws Exception { + com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config() + .setAccessKeyId(System.getenv("ALIBABA_CLOUD_ACCESS_KEY_ID")) + .setAccessKeySecret(System.getenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET")); + // 下方接入地址以公有云的公网接入地址为例,可按需更换接入地址。 + config.endpoint = "bailian.cn-beijing.aliyuncs.com"; + return new com.aliyun.bailian20231229.Client(config); + } + + /** + * 申请文档上传租约。 + * + * @param client 客户端对象 + * @param categoryId 类目ID + * @param fileName 文档名称 + * @param fileMd5 文档的MD5值 + * @param fileSize 文档大小(以字节为单位) + * @param workspaceId 业务空间ID + * @return 阿里云百炼服务的响应对象 + */ + public static ApplyFileUploadLeaseResponse applyLease(com.aliyun.bailian20231229.Client client, String categoryId, + String fileName, String fileMd5, String fileSize, String workspaceId) throws Exception { + Map headers = new HashMap<>(); + com.aliyun.bailian20231229.models.ApplyFileUploadLeaseRequest applyFileUploadLeaseRequest = new com.aliyun.bailian20231229.models.ApplyFileUploadLeaseRequest(); + applyFileUploadLeaseRequest.setFileName(fileName); + applyFileUploadLeaseRequest.setMd5(fileMd5); + applyFileUploadLeaseRequest.setSizeInBytes(fileSize); + com.aliyun.teautil.models.RuntimeOptions runtime = new com.aliyun.teautil.models.RuntimeOptions(); + ApplyFileUploadLeaseResponse applyFileUploadLeaseResponse = null; + applyFileUploadLeaseResponse = client.applyFileUploadLeaseWithOptions(categoryId, workspaceId, + applyFileUploadLeaseRequest, headers, runtime); + return applyFileUploadLeaseResponse; + } + + /** + * 上传文档到临时存储。 + * + * @param preSignedUrl 上传租约中的 URL + * @param headers 上传请求的头部 + * @param filePath 文档本地路径 + * @throws Exception 如果上传过程中发生错误 + */ + public static void uploadFile(String preSignedUrl, Map headers, String filePath) throws Exception { + File file = new File(filePath); + if (!file.exists() || !file.isFile()) { + throw new IllegalArgumentException("文件不存在或不是普通文件: " + filePath); + } + + try (FileInputStream fis = new FileInputStream(file)) { + URL url = new URL(preSignedUrl); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("PUT"); + conn.setDoOutput(true); + + // 设置上传请求头 + conn.setRequestProperty("X-bailian-extra", headers.get("X-bailian-extra")); + conn.setRequestProperty("Content-Type", headers.get("Content-Type")); + + // 分块读取并上传文档 + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = fis.read(buffer)) != -1) { + conn.getOutputStream().write(buffer, 0, bytesRead); + } + + int responseCode = conn.getResponseCode(); + if (responseCode != 200) { + throw new RuntimeException("上传失败: " + responseCode); + } + } + } + + /** + * 将文档添加到类目中。 + * + * @param client 客户端对象 + * @param leaseId 租约ID + * @param parser 用于文档的解析器 + * @param categoryId 类目ID + * @param workspaceId 业务空间ID + * @return 阿里云百炼服务的响应对象 + */ + public static AddFileResponse addFile(com.aliyun.bailian20231229.Client client, String leaseId, String parser, + String categoryId, String workspaceId) throws Exception { + Map headers = new HashMap<>(); + com.aliyun.bailian20231229.models.AddFileRequest addFileRequest = new com.aliyun.bailian20231229.models.AddFileRequest(); + addFileRequest.setLeaseId(leaseId); + addFileRequest.setParser(parser); + addFileRequest.setCategoryId(categoryId); + com.aliyun.teautil.models.RuntimeOptions runtime = new com.aliyun.teautil.models.RuntimeOptions(); + return client.addFileWithOptions(workspaceId, addFileRequest, headers, runtime); + } + + /** + * 查询文档的基本信息。 + * + * @param client 客户端对象 + * @param workspaceId 业务空间ID + * @param fileId 文档ID + * @return 阿里云百炼服务的响应对象 + */ + public static DescribeFileResponse describeFile(com.aliyun.bailian20231229.Client client, String workspaceId, + String fileId) throws Exception { + Map headers = new HashMap<>(); + com.aliyun.teautil.models.RuntimeOptions runtime = new com.aliyun.teautil.models.RuntimeOptions(); + return client.describeFileWithOptions(workspaceId, fileId, headers, runtime); + } + + /** + * 在阿里云百炼服务中创建知识库(初始化)。 + * + * @param client 客户端对象 + * @param workspaceId 业务空间ID + * @param fileId 文档ID + * @param name 知识库名称 + * @param structureType 知识库的数据类型 + * @param sourceType 应用数据的数据类型,支持类目类型和文档类型 + * @param sinkType 知识库的向量存储类型 + * @return 阿里云百炼服务的响应对象 + */ + public static CreateIndexResponse createIndex(com.aliyun.bailian20231229.Client client, String workspaceId, + String fileId, String name, String structureType, String sourceType, String sinkType) throws Exception { + Map headers = new HashMap<>(); + com.aliyun.bailian20231229.models.CreateIndexRequest createIndexRequest = new com.aliyun.bailian20231229.models.CreateIndexRequest(); + createIndexRequest.setStructureType(structureType); + createIndexRequest.setName(name); + createIndexRequest.setSourceType(sourceType); + createIndexRequest.setSinkType(sinkType); + createIndexRequest.setDocumentIds(Collections.singletonList(fileId)); + com.aliyun.teautil.models.RuntimeOptions runtime = new com.aliyun.teautil.models.RuntimeOptions(); + return client.createIndexWithOptions(workspaceId, createIndexRequest, headers, runtime); + } + + /** + * 向阿里云百炼服务提交索引任务。 + * + * @param client 客户端对象 + * @param workspaceId 业务空间ID + * @param indexId 知识库ID + * @return 阿里云百炼服务的响应对象 + */ + public static SubmitIndexJobResponse submitIndex(com.aliyun.bailian20231229.Client client, String workspaceId, + String indexId) throws Exception { + Map headers = new HashMap<>(); + com.aliyun.bailian20231229.models.SubmitIndexJobRequest submitIndexJobRequest = new com.aliyun.bailian20231229.models.SubmitIndexJobRequest(); + submitIndexJobRequest.setIndexId(indexId); + com.aliyun.teautil.models.RuntimeOptions runtime = new com.aliyun.teautil.models.RuntimeOptions(); + return client.submitIndexJobWithOptions(workspaceId, submitIndexJobRequest, headers, runtime); + } + + /** + * 查询索引任务状态。 + * + * @param client 客户端对象 + * @param workspaceId 业务空间ID + * @param jobId 任务ID + * @param indexId 知识库ID + * @return 阿里云百炼服务的响应对象 + */ + public static GetIndexJobStatusResponse getIndexJobStatus(com.aliyun.bailian20231229.Client client, + String workspaceId, String jobId, String indexId) throws Exception { + Map headers = new HashMap<>(); + com.aliyun.bailian20231229.models.GetIndexJobStatusRequest getIndexJobStatusRequest = new com.aliyun.bailian20231229.models.GetIndexJobStatusRequest(); + getIndexJobStatusRequest.setIndexId(indexId); + getIndexJobStatusRequest.setJobId(jobId); + com.aliyun.teautil.models.RuntimeOptions runtime = new com.aliyun.teautil.models.RuntimeOptions(); + GetIndexJobStatusResponse getIndexJobStatusResponse = null; + getIndexJobStatusResponse = client.getIndexJobStatusWithOptions(workspaceId, getIndexJobStatusRequest, headers, + runtime); + return getIndexJobStatusResponse; + } + + /** + * 使用阿里云百炼服务创建知识库。 + * + * @param filePath 文档本地路径 + * @param workspaceId 业务空间ID + * @param name 知识库名称 + * @return 如果成功,返回知识库ID;否则返回 null + */ + public static String createKnowledgeBase(String filePath, String workspaceId, String name) { + // 设置默认值 + String categoryId = "default"; + String parser = "DASHSCOPE_DOCMIND"; + String sourceType = "DATA_CENTER_FILE"; + String structureType = "unstructured"; + String sinkType = "DEFAULT"; + try { + // 步骤1:初始化客户端(Client) + System.out.println("步骤1:初始化Client"); + com.aliyun.bailian20231229.Client client = createClient(); + + // 步骤2:准备文档信息 + System.out.println("步骤2:准备文档信息"); + String fileName = new File(filePath).getName(); + String fileMd5 = calculateMD5(filePath); + String fileSize = getFileSize(filePath); + + // 步骤3:申请上传租约 + System.out.println("步骤3:向阿里云百炼申请上传租约"); + ApplyFileUploadLeaseResponse leaseResponse = applyLease(client, categoryId, fileName, fileMd5, fileSize, + workspaceId); + String leaseId = leaseResponse.getBody().getData().getFileUploadLeaseId(); + String uploadUrl = leaseResponse.getBody().getData().getParam().getUrl(); + Object uploadHeaders = leaseResponse.getBody().getData().getParam().getHeaders(); + + // 步骤4:上传文档 + System.out.println("步骤4:上传文档到阿里云百炼"); + // 请自行安装jackson-databind + // 将上一步的uploadHeaders转换为Map(Key-Value形式) + ObjectMapper mapper = new ObjectMapper(); + Map uploadHeadersMap = (Map) mapper + .readValue(mapper.writeValueAsString(uploadHeaders), Map.class); + uploadFile(uploadUrl, uploadHeadersMap, filePath); + + // 步骤5:将文档添加到服务器 + System.out.println("步骤5:将文档添加到阿里云百炼服务器"); + AddFileResponse addResponse = addFile(client, leaseId, parser, categoryId, workspaceId); + String fileId = addResponse.getBody().getData().getFileId(); + + // 步骤6:检查文档状态 + System.out.println("步骤6:检查阿里云百炼中的文档状态"); + while (true) { + DescribeFileResponse describeResponse = describeFile(client, workspaceId, fileId); + String status = describeResponse.getBody().getData().getStatus(); + System.out.println("当前文档状态:" + status); + + if (status.equals("INIT")) { + System.out.println("文档待解析,请稍候..."); + } else if (status.equals("PARSING")) { + System.out.println("文档解析中,请稍候..."); + } else if (status.equals("PARSE_SUCCESS")) { + System.out.println("文档解析完成!"); + break; + } else { + System.out.println("未知的文档状态:" + status + ",请联系技术支持。"); + return null; + } + TimeUnit.SECONDS.sleep(5); + } + + // 步骤7:初始化知识库 + System.out.println("步骤7:在阿里云百炼中创建知识库"); + CreateIndexResponse indexResponse = createIndex(client, workspaceId, fileId, name, structureType, + sourceType, sinkType); + String indexId = indexResponse.getBody().getData().getId(); + + // 步骤8:提交索引任务 + System.out.println("步骤8:向阿里云百炼提交索引任务"); + SubmitIndexJobResponse submitResponse = submitIndex(client, workspaceId, indexId); + String jobId = submitResponse.getBody().getData().getId(); + + // 步骤9:获取索引任务状态 + System.out.println("步骤9:获取阿里云百炼索引任务状态"); + while (true) { + GetIndexJobStatusResponse getStatusResponse = getIndexJobStatus(client, workspaceId, jobId, indexId); + String status = getStatusResponse.getBody().getData().getStatus(); + System.out.println("当前索引任务状态:" + status); + + if (status.equals("COMPLETED")) { + break; + } + TimeUnit.SECONDS.sleep(5); + } + + System.out.println("阿里云百炼知识库创建成功!"); + return indexId; + + } catch (Exception e) { + System.out.println("发生错误:" + e.getMessage()); + e.printStackTrace(); + return null; + } + } + + /** + * 主函数。 + */ + public static void main(String[] args) { + Scanner scanner = new Scanner(System.in); + if (!checkEnvironmentVariables()) { + return; + } + + System.out.print("请输入您需要上传文档的实际本地路径(以Linux为例:/xxx/xxx/阿里云百炼系列手机产品介绍.docx):"); + String filePath = scanner.nextLine(); + + System.out.print("请为您的知识库输入一个名称:"); + String kbName = scanner.nextLine(); + + String workspaceId = System.getenv("WORKSPACE_ID"); + String result = createKnowledgeBase(filePath, workspaceId, kbName); + if (result != null) { + System.out.println("知识库ID: " + result); + } + } +} diff --git a/src/main/java/com/gxwebsoft/ai/util/KnowledgeBaseManage.java b/src/main/java/com/gxwebsoft/ai/util/KnowledgeBaseManage.java new file mode 100644 index 0000000..c669d52 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/util/KnowledgeBaseManage.java @@ -0,0 +1,145 @@ +package com.gxwebsoft.ai.util; + +import com.aliyun.bailian20231229.models.DeleteIndexResponse; +import com.aliyun.bailian20231229.models.ListIndicesResponse; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.*; + +/** + * 管理知识库 + * @author GIIT-YC + * + */ +public class KnowledgeBaseManage { + + String ALIBABA_CLOUD_ACCESS_KEY_ID = "LTAI5tD5YRKuxWz6Eg7qrM4P"; + String ALIBABA_CLOUD_ACCESS_KEY_SECRET = "bO8TBDXflOwbtSKimPpG8XrJnyzgTk"; + String WORKSPACE_ID = "llm-4pf5auwewoz34zqu"; + + /** + * 检查并提示设置必要的环境变量。 + * + * @return true 如果所有必需的环境变量都已设置,否则 false + */ + public static boolean checkEnvironmentVariables() { + Map requiredVars = new HashMap<>(); + requiredVars.put("ALIBABA_CLOUD_ACCESS_KEY_ID", "阿里云访问密钥ID"); + requiredVars.put("ALIBABA_CLOUD_ACCESS_KEY_SECRET", "阿里云访问密钥密码"); + requiredVars.put("WORKSPACE_ID", "阿里云百炼业务空间ID"); + + List missingVars = new ArrayList<>(); + for (Map.Entry entry : requiredVars.entrySet()) { + String value = System.getenv(entry.getKey()); + if (value == null || value.isEmpty()) { + missingVars.add(entry.getKey()); + System.out.println("错误:请设置 " + entry.getKey() + " 环境变量 (" + entry.getValue() + ")"); + } + } + + return missingVars.isEmpty(); + } + + /** + * 创建并配置客户端(Client) + * + * @return 配置好的客户端(Client) + */ + public static com.aliyun.bailian20231229.Client createClient() throws Exception { + com.aliyun.credentials.Client credential = new com.aliyun.credentials.Client(); + com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config() + .setCredential(credential); + // 下方接入地址以公有云的公网接入地址为例,可按需更换接入地址。 + config.endpoint = "bailian.cn-beijing.aliyuncs.com"; + return new com.aliyun.bailian20231229.Client(config); + } + + /** + * 获取指定业务空间下一个或多个知识库的详细信息 + * + * @param client 客户端(Client) + * @param workspaceId 业务空间ID + * @return 阿里云百炼服务的响应 + */ + public static ListIndicesResponse listIndices(com.aliyun.bailian20231229.Client client, String workspaceId) + throws Exception { + Map headers = new HashMap<>(); + com.aliyun.bailian20231229.models.ListIndicesRequest listIndicesRequest = new com.aliyun.bailian20231229.models.ListIndicesRequest(); + com.aliyun.teautil.models.RuntimeOptions runtime = new com.aliyun.teautil.models.RuntimeOptions(); + return client.listIndicesWithOptions(workspaceId, listIndicesRequest, headers, runtime); + } + + /** + * 永久性删除指定的知识库 + * + * @param client 客户端(Client) + * @param workspaceId 业务空间ID + * @param indexId 知识库ID + * @return 阿里云百炼服务的响应 + */ + public static DeleteIndexResponse deleteIndex(com.aliyun.bailian20231229.Client client, String workspaceId, + String indexId) throws Exception { + Map headers = new HashMap<>(); + com.aliyun.bailian20231229.models.DeleteIndexRequest deleteIndexRequest = new com.aliyun.bailian20231229.models.DeleteIndexRequest(); + deleteIndexRequest.setIndexId(indexId); + com.aliyun.teautil.models.RuntimeOptions runtime = new com.aliyun.teautil.models.RuntimeOptions(); + return client.deleteIndexWithOptions(workspaceId, deleteIndexRequest, headers, runtime); + } + + /** + * 主函数 + */ + public static void main(String[] args) { + if (!checkEnvironmentVariables()) { + System.out.println("环境变量校验未通过。"); + return; + } + + try { + Scanner scanner = new Scanner(System.in); + System.out.print("请选择要执行的操作:\n1. 查看知识库\n2. 删除知识库\n请输入选项(1或2):"); + String startOption = scanner.nextLine(); + com.aliyun.bailian20231229.Client client = createClient(); + if (startOption.equals("1")) { + // 查看知识库 + System.out.println("\n执行查看知识库"); + String workspaceId = System.getenv("WORKSPACE_ID"); + ListIndicesResponse response = listIndices(client, workspaceId); + // 请自行安装jackson-databind。将响应转换为 JSON 字符串 + ObjectMapper mapper = new ObjectMapper(); + String result = mapper.writeValueAsString(response.getBody().getData()); + System.out.println(result); + } else if (startOption.equals("2")) { + System.out.println("\n执行删除知识库"); + String workspaceId = System.getenv("WORKSPACE_ID"); + System.out.print("请输入知识库ID:"); // 即 CreateIndex 接口返回的 Data.Id,您也可以在阿里云百炼控制台的知识库页面获取。 + String indexId = scanner.nextLine(); + // 删除前二次确认 + boolean confirm = false; + while (!confirm) { + System.out.print("您确定要永久性删除该知识库 " + indexId + " 吗?(y/n): "); + String input = scanner.nextLine().trim().toLowerCase(); + if (input.equals("y")) { + confirm = true; + } else if (input.equals("n")) { + System.out.println("已取消删除操作。"); + return; + } else { + System.out.println("无效输入,请输入 y 或 n。"); + } + } + DeleteIndexResponse resp = deleteIndex(client, workspaceId, indexId); + if (resp.getBody().getStatus().equals("200")) { + System.out.println("知识库" + indexId + "删除成功!"); + } else { + ObjectMapper mapper = new ObjectMapper(); + System.out.println("发生错误:" + mapper.writeValueAsString(resp.getBody())); + } + } else { + System.out.println("无效的选项,程序退出。"); + } + } catch (Exception e) { + System.out.println("发生错误:" + e.getMessage()); + } + } +} diff --git a/src/main/java/com/gxwebsoft/ai/util/KnowledgeBaseRetrieve.java b/src/main/java/com/gxwebsoft/ai/util/KnowledgeBaseRetrieve.java new file mode 100644 index 0000000..a7b568e --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/util/KnowledgeBaseRetrieve.java @@ -0,0 +1,110 @@ +package com.gxwebsoft.ai.util; + +import com.aliyun.bailian20231229.models.RetrieveRequest; +import com.aliyun.bailian20231229.models.RetrieveResponse; +import com.aliyun.teautil.models.RuntimeOptions; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.*; + +/** + * 检索知识库 + * @author GIIT-YC + * + */ +public class KnowledgeBaseRetrieve { + + static String ALIBABA_CLOUD_ACCESS_KEY_ID = "LTAI5tD5YRKuxWz6Eg7qrM4P"; + static String ALIBABA_CLOUD_ACCESS_KEY_SECRET = "bO8TBDXflOwbtSKimPpG8XrJnyzgTk"; + static String WORKSPACE_ID = "llm-4pf5auwewoz34zqu"; + + + /** + * 检查并提示设置必要的环境变量。 + * + * @return true 如果所有必需的环境变量都已设置,否则 false + */ + public static boolean checkEnvironmentVariables() { + Map requiredVars = new HashMap<>(); + requiredVars.put("ALIBABA_CLOUD_ACCESS_KEY_ID", "阿里云访问密钥ID"); + requiredVars.put("ALIBABA_CLOUD_ACCESS_KEY_SECRET", "阿里云访问密钥密码"); + requiredVars.put("WORKSPACE_ID", "阿里云百炼业务空间ID"); + + List missingVars = new ArrayList<>(); + for (Map.Entry entry : requiredVars.entrySet()) { + String value = System.getenv(entry.getKey()); + if (value == null || value.isEmpty()) { + missingVars.add(entry.getKey()); + System.out.println("错误:请设置 " + entry.getKey() + " 环境变量 (" + entry.getValue() + ")"); + } + } + + return missingVars.isEmpty(); + } + + /** + * 初始化客户端(Client)。 + * + * @return 配置好的客户端对象 + */ + public static com.aliyun.bailian20231229.Client createClient() throws Exception { + com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config() + .setAccessKeyId(ALIBABA_CLOUD_ACCESS_KEY_ID) + .setAccessKeySecret(ALIBABA_CLOUD_ACCESS_KEY_SECRET); + // 下方接入地址以公有云的公网接入地址为例,可按需更换接入地址。 + config.endpoint = "bailian.cn-beijing.aliyuncs.com"; + return new com.aliyun.bailian20231229.Client(config); + } + + /** + * 在指定的知识库中检索信息。 + * + * @param client 客户端对象(bailian20231229Client) + * @param workspaceId 业务空间ID + * @param indexId 知识库ID + * @param query 检索查询语句 + * @return 阿里云百炼服务的响应 + */ + public static RetrieveResponse retrieveIndex(com.aliyun.bailian20231229.Client client, String workspaceId, + String indexId, String query) throws Exception { + RetrieveRequest retrieveRequest = new RetrieveRequest(); + retrieveRequest.setIndexId(indexId); + retrieveRequest.setQuery(query); + retrieveRequest.setDenseSimilarityTopK(null); + RuntimeOptions runtime = new RuntimeOptions(); + return client.retrieveWithOptions(workspaceId, retrieveRequest, null, runtime); + } + + /** + * 使用阿里云百炼服务检索知识库。 + */ + public static void main(String[] args) { +// if (!checkEnvironmentVariables()) { +// System.out.println("环境变量校验未通过。"); +// return; +// } + + try { + // 步骤1:初始化客户端(Client) + System.out.println("步骤1:创建Client"); + com.aliyun.bailian20231229.Client client = createClient(); + + // 步骤2:检索知识库 + System.out.println("步骤2:检索知识库"); + Scanner scanner = new Scanner(System.in); + System.out.print("请输入知识库ID:"); // 即 CreateIndex 接口返回的 Data.Id,您也可以在阿里云百炼控制台的知识库页面获取。 + String indexId = scanner.nextLine(); + System.out.print("请输入检索query:"); + String query = scanner.nextLine(); + String workspaceId = WORKSPACE_ID; + RetrieveResponse resp = retrieveIndex(client, workspaceId, indexId, query); + + // 请自行安装jackson-databind。将响应体responsebody转换为 JSON 字符串 + ObjectMapper mapper = new ObjectMapper(); + String result = mapper.writeValueAsString(resp.getBody()); + System.out.println(result); + } catch (Exception e) { + System.out.println("发生错误:" + e.getMessage()); + } + } +} diff --git a/src/main/java/com/gxwebsoft/ai/util/KnowledgeBaseUpdate.java b/src/main/java/com/gxwebsoft/ai/util/KnowledgeBaseUpdate.java new file mode 100644 index 0000000..518d3e6 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/util/KnowledgeBaseUpdate.java @@ -0,0 +1,384 @@ +package com.gxwebsoft.ai.util; + +import com.aliyun.bailian20231229.models.*; +import com.aliyun.teautil.models.RuntimeOptions; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.File; +import java.io.FileInputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.util.*; + +/** + * 更新知识库 + * @author GIIT-YC + * + */ +public class KnowledgeBaseUpdate { + + String ALIBABA_CLOUD_ACCESS_KEY_ID = "LTAI5tD5YRKuxWz6Eg7qrM4P"; + String ALIBABA_CLOUD_ACCESS_KEY_SECRET = "bO8TBDXflOwbtSKimPpG8XrJnyzgTk"; + String WORKSPACE_ID = "llm-4pf5auwewoz34zqu"; + + /** + * 检查并提示设置必要的环境变量。 + * + * @return true 如果所有必需的环境变量都已设置,否则 false + */ + public static boolean checkEnvironmentVariables() { + Map requiredVars = new HashMap<>(); + requiredVars.put("ALIBABA_CLOUD_ACCESS_KEY_ID", "阿里云访问密钥ID"); + requiredVars.put("ALIBABA_CLOUD_ACCESS_KEY_SECRET", "阿里云访问密钥密码"); + requiredVars.put("WORKSPACE_ID", "阿里云百炼业务空间ID"); + + List missingVars = new ArrayList<>(); + for (Map.Entry entry : requiredVars.entrySet()) { + String value = System.getenv(entry.getKey()); + if (value == null || value.isEmpty()) { + missingVars.add(entry.getKey()); + System.out.println("错误:请设置 " + entry.getKey() + " 环境变量 (" + entry.getValue() + ")"); + } + } + + return missingVars.isEmpty(); + } + + /** + * 创建并配置客户端(Client) + * + * @return 配置好的客户端(Client) + */ + public static com.aliyun.bailian20231229.Client createClient() throws Exception { + com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config() + .setAccessKeyId(System.getenv("ALIBABA_CLOUD_ACCESS_KEY_ID")) + .setAccessKeySecret(System.getenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET")); + // 下方接入地址以公有云的公网接入地址为例,可按需更换接入地址。 + config.endpoint = "bailian.cn-beijing.aliyuncs.com"; + return new com.aliyun.bailian20231229.Client(config); + } + + /** + * 计算文档的MD5值 + * + * @param filePath 文档本地路径 + * @return 文档的MD5值 + */ + public static String calculateMD5(String filePath) throws Exception { + MessageDigest md = MessageDigest.getInstance("MD5"); + try (FileInputStream fis = new FileInputStream(filePath)) { + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = fis.read(buffer)) != -1) { + md.update(buffer, 0, bytesRead); + } + } + StringBuilder sb = new StringBuilder(); + for (byte b : md.digest()) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } + + /** + * 获取文档大小(以字节为单位) + * + * @param filePath 文档本地路径 + * @return 文档大小(以字节为单位) + */ + public static String getFileSize(String filePath) { + File file = new File(filePath); + long fileSize = file.length(); + return String.valueOf(fileSize); + } + + /** + * 申请文档上传租约。 + * + * @param client 客户端对象 + * @param categoryId 类目ID + * @param fileName 文档名称 + * @param fileMd5 文档的MD5值 + * @param fileSize 文档大小(以字节为单位) + * @param workspaceId 业务空间ID + * @return 阿里云百炼服务的响应对象 + */ + public static ApplyFileUploadLeaseResponse applyLease(com.aliyun.bailian20231229.Client client, String categoryId, + String fileName, String fileMd5, String fileSize, String workspaceId) throws Exception { + Map headers = new HashMap<>(); + com.aliyun.bailian20231229.models.ApplyFileUploadLeaseRequest applyFileUploadLeaseRequest = new com.aliyun.bailian20231229.models.ApplyFileUploadLeaseRequest(); + applyFileUploadLeaseRequest.setFileName(fileName); + applyFileUploadLeaseRequest.setMd5(fileMd5); + applyFileUploadLeaseRequest.setSizeInBytes(fileSize); + com.aliyun.teautil.models.RuntimeOptions runtime = new com.aliyun.teautil.models.RuntimeOptions(); + ApplyFileUploadLeaseResponse applyFileUploadLeaseResponse = null; + applyFileUploadLeaseResponse = client.applyFileUploadLeaseWithOptions(categoryId, workspaceId, + applyFileUploadLeaseRequest, headers, runtime); + return applyFileUploadLeaseResponse; + } + + /** + * 上传文档到临时存储。 + * + * @param preSignedUrl 上传租约中的 URL + * @param headers 上传请求的头部 + * @param filePath 文档本地路径 + * @throws Exception 如果上传过程中发生错误 + */ + public static void uploadFile(String preSignedUrl, Map headers, String filePath) throws Exception { + File file = new File(filePath); + if (!file.exists() || !file.isFile()) { + throw new IllegalArgumentException("文件不存在或不是普通文件: " + filePath); + } + + try (FileInputStream fis = new FileInputStream(file)) { + URL url = new URL(preSignedUrl); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("PUT"); + conn.setDoOutput(true); + + // 设置上传请求头 + conn.setRequestProperty("X-bailian-extra", headers.get("X-bailian-extra")); + conn.setRequestProperty("Content-Type", headers.get("Content-Type")); + + // 分块读取并上传文档 + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = fis.read(buffer)) != -1) { + conn.getOutputStream().write(buffer, 0, bytesRead); + } + + int responseCode = conn.getResponseCode(); + if (responseCode != 200) { + throw new RuntimeException("上传失败: " + responseCode); + } + } + } + + /** + * 将文档添加到类目中。 + * + * @param client 客户端对象 + * @param leaseId 租约ID + * @param parser 用于文档的解析器 + * @param categoryId 类目ID + * @param workspaceId 业务空间ID + * @return 阿里云百炼服务的响应对象 + */ + public static AddFileResponse addFile(com.aliyun.bailian20231229.Client client, String leaseId, String parser, + String categoryId, String workspaceId) throws Exception { + Map headers = new HashMap<>(); + com.aliyun.bailian20231229.models.AddFileRequest addFileRequest = new com.aliyun.bailian20231229.models.AddFileRequest(); + addFileRequest.setLeaseId(leaseId); + addFileRequest.setParser(parser); + addFileRequest.setCategoryId(categoryId); + com.aliyun.teautil.models.RuntimeOptions runtime = new com.aliyun.teautil.models.RuntimeOptions(); + return client.addFileWithOptions(workspaceId, addFileRequest, headers, runtime); + } + + /** + * 查询文档的基本信息。 + * + * @param client 客户端对象 + * @param workspaceId 业务空间ID + * @param fileId 文档ID + * @return 阿里云百炼服务的响应对象 + */ + public static DescribeFileResponse describeFile(com.aliyun.bailian20231229.Client client, String workspaceId, + String fileId) throws Exception { + Map headers = new HashMap<>(); + com.aliyun.teautil.models.RuntimeOptions runtime = new com.aliyun.teautil.models.RuntimeOptions(); + return client.describeFileWithOptions(workspaceId, fileId, headers, runtime); + } + + /** + * 向一个非结构化知识库追加导入已解析的文档 + * + * @param client 客户端(Client) + * @param workspaceId 业务空间ID + * @param indexId 知识库ID + * @param fileId 文档ID + * @param sourceType 数据类型 + * @return 阿里云百炼服务的响应 + */ + public static SubmitIndexAddDocumentsJobResponse submitIndexAddDocumentsJob( + com.aliyun.bailian20231229.Client client, String workspaceId, String indexId, String fileId, + String sourceType) throws Exception { + Map headers = new HashMap<>(); + SubmitIndexAddDocumentsJobRequest submitIndexAddDocumentsJobRequest = new SubmitIndexAddDocumentsJobRequest(); + submitIndexAddDocumentsJobRequest.setIndexId(indexId); + submitIndexAddDocumentsJobRequest.setDocumentIds(Collections.singletonList(fileId)); + submitIndexAddDocumentsJobRequest.setSourceType(sourceType); + RuntimeOptions runtime = new RuntimeOptions(); + return client.submitIndexAddDocumentsJobWithOptions(workspaceId, submitIndexAddDocumentsJobRequest, headers, + runtime); + } + + /** + * 查询索引任务状态。 + * + * @param client 客户端对象 + * @param workspaceId 业务空间ID + * @param jobId 任务ID + * @param indexId 知识库ID + * @return 阿里云百炼服务的响应对象 + */ + public static GetIndexJobStatusResponse getIndexJobStatus(com.aliyun.bailian20231229.Client client, + String workspaceId, String jobId, String indexId) throws Exception { + Map headers = new HashMap<>(); + com.aliyun.bailian20231229.models.GetIndexJobStatusRequest getIndexJobStatusRequest = new com.aliyun.bailian20231229.models.GetIndexJobStatusRequest(); + getIndexJobStatusRequest.setIndexId(indexId); + getIndexJobStatusRequest.setJobId(jobId); + com.aliyun.teautil.models.RuntimeOptions runtime = new com.aliyun.teautil.models.RuntimeOptions(); + GetIndexJobStatusResponse getIndexJobStatusResponse = null; + getIndexJobStatusResponse = client.getIndexJobStatusWithOptions(workspaceId, getIndexJobStatusRequest, headers, + runtime); + return getIndexJobStatusResponse; + } + + /** + * 从指定的非结构化知识库中永久删除一个或多个文档 + * + * @param client 客户端(Client) + * @param workspaceId 业务空间ID + * @param indexId 知识库ID + * @param fileId 文档ID + * @return 阿里云百炼服务的响应 + */ + public static DeleteIndexDocumentResponse deleteIndexDocument(com.aliyun.bailian20231229.Client client, + String workspaceId, String indexId, String fileId) throws Exception { + Map headers = new HashMap<>(); + DeleteIndexDocumentRequest deleteIndexDocumentRequest = new DeleteIndexDocumentRequest(); + deleteIndexDocumentRequest.setIndexId(indexId); + deleteIndexDocumentRequest.setDocumentIds(Collections.singletonList(fileId)); + com.aliyun.teautil.models.RuntimeOptions runtime = new com.aliyun.teautil.models.RuntimeOptions(); + return client.deleteIndexDocumentWithOptions(workspaceId, deleteIndexDocumentRequest, headers, runtime); + } + + /** + * 使用阿里云百炼服务更新知识库 + * + * @param filePath 文档(更新后的)的实际本地路径 + * @param workspaceId 业务空间ID + * @param indexId 需要更新的知识库ID + * @param oldFileId 需要更新的文档的FileID + * @return 如果成功,返回知识库ID;否则返回 null + */ + public static String updateKnowledgeBase(String filePath, String workspaceId, String indexId, String oldFileId) { + // 设置默认值 + String categoryId = "default"; + String parser = "DASHSCOPE_DOCMIND"; + String sourceType = "DATA_CENTER_FILE"; + try { + // 步骤1:初始化客户端(Client) + System.out.println("步骤1:创建Client"); + com.aliyun.bailian20231229.Client client = createClient(); + + // 步骤2:准备文档信息(更新后的文档) + System.out.println("步骤2:准备文档信息"); + String fileName = Paths.get(filePath).getFileName().toString(); + String fileMd5 = calculateMD5(filePath); + String fileSize = getFileSize(filePath); + + // 步骤3:申请上传租约 + System.out.println("步骤3:向阿里云百炼申请上传租约"); + ApplyFileUploadLeaseResponse leaseResponse = applyLease(client, categoryId, fileName, fileMd5, fileSize, + workspaceId); + String leaseId = leaseResponse.getBody().getData().getFileUploadLeaseId(); + String uploadUrl = leaseResponse.getBody().getData().getParam().getUrl(); + Object uploadHeaders = leaseResponse.getBody().getData().getParam().getHeaders(); + + // 步骤4:上传文档到临时存储 + System.out.println("步骤4:上传文档到临时存储"); + // 请自行安装jackson-databind + // 将上一步的uploadHeaders转换为Map(Key-Value形式) + ObjectMapper mapper = new ObjectMapper(); + Map uploadHeadersMap = (Map) mapper + .readValue(mapper.writeValueAsString(uploadHeaders), Map.class); + uploadFile(uploadUrl, uploadHeadersMap, filePath); + + // 步骤5:添加文档到类目中 + System.out.println("步骤5:添加文档到类目中"); + AddFileResponse addResponse = addFile(client, leaseId, parser, categoryId, workspaceId); + String fileId = addResponse.getBody().getData().getFileId(); + + // 步骤6:检查更新后的文档状态 + System.out.println("步骤6:检查阿里云百炼中的文档状态"); + while (true) { + DescribeFileResponse describeResponse = describeFile(client, workspaceId, fileId); + String status = describeResponse.getBody().getData().getStatus(); + System.out.println("当前文档状态:" + status); + if ("INIT".equals(status)) { + System.out.println("文档待解析,请稍候..."); + } else if ("PARSING".equals(status)) { + System.out.println("文档解析中,请稍候..."); + } else if ("PARSE_SUCCESS".equals(status)) { + System.out.println("文档解析完成!"); + break; + } else { + System.out.println("未知的文档状态:" + status + ",请联系技术支持。"); + return null; + } + Thread.sleep(5000); + } + + // 步骤7:提交追加文档任务 + System.out.println("步骤7:提交追加文档任务"); + SubmitIndexAddDocumentsJobResponse indexAddResponse = submitIndexAddDocumentsJob(client, workspaceId, + indexId, fileId, sourceType); + String jobId = indexAddResponse.getBody().getData().getId(); + + // 步骤8:等待追加任务完成 + System.out.println("步骤8:等待追加任务完成"); + while (true) { + GetIndexJobStatusResponse jobStatusResponse = getIndexJobStatus(client, workspaceId, jobId, indexId); + String status = jobStatusResponse.getBody().getData().getStatus(); + System.out.println("当前索引任务状态:" + status); + if ("COMPLETED".equals(status)) { + break; + } + Thread.sleep(5000); + } + + // 步骤9:删除旧文档 + System.out.println("步骤9:删除旧文档"); + deleteIndexDocument(client, workspaceId, indexId, oldFileId); + + System.out.println("阿里云百炼知识库更新成功!"); + return indexId; + } catch (Exception e) { + System.out.println("发生错误:" + e.getMessage()); + return null; + } + } + + /** + * 主函数。 + */ + public static void main(String[] args) { + if (!checkEnvironmentVariables()) { + System.out.println("环境变量校验未通过。"); + return; + } + + Scanner scanner = new Scanner(System.in); + System.out.print("请输入您需要上传文档(更新后的)的实际本地路径(以Linux为例:/xxx/xxx/阿里云百炼系列手机产品介绍.docx):"); + String filePath = scanner.nextLine(); + + System.out.print("请输入需要更新的知识库ID:"); // 即 CreateIndex 接口返回的 Data.Id,您也可以在阿里云百炼控制台的知识库页面获取。 + String indexId = scanner.nextLine(); // 即 AddFile 接口返回的 FileId。您也可以在阿里云百炼控制台的应用数据页面,单击文件名称旁的 ID 图标获取。 + + System.out.print("请输入需要更新的文档的 FileID:"); + String oldFileId = scanner.nextLine(); + + String workspaceId = System.getenv("WORKSPACE_ID"); + String result = updateKnowledgeBase(filePath, workspaceId, indexId, oldFileId); + if (result != null) { + System.out.println("知识库更新成功,返回知识库ID: " + result); + } else { + System.out.println("知识库更新失败。"); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/gxwebsoft/enterprise/controller/EnterpriseController.java b/src/main/java/com/gxwebsoft/enterprise/controller/EnterpriseController.java new file mode 100644 index 0000000..819568a --- /dev/null +++ b/src/main/java/com/gxwebsoft/enterprise/controller/EnterpriseController.java @@ -0,0 +1,147 @@ +package com.gxwebsoft.enterprise.controller; + +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.core.annotation.OperationLog; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.enterprise.entity.Enterprise; +import com.gxwebsoft.enterprise.service.EnterpriseService; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import java.util.List; + +/** + * + * @author GIIT-YC + * + */ +@Tag(name = "企业信息管理") +@RestController +@RequestMapping("/api/enterprise/enterprise") +public class EnterpriseController extends BaseController { + + @Resource + private EnterpriseService enterpriseService; + +// @PreAuthorize("hasAuthority('enterprise:enterprise:list')") + @Operation(summary = "分页查询企业信息") + @GetMapping("/page") + public ApiResult> page(Enterprise enterprise) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.like(StrUtil.isNotBlank(enterprise.getName()), Enterprise::getName, enterprise.getName()); + wrapper.like(StrUtil.isNotBlank(enterprise.getCreditCode()), Enterprise::getCreditCode, enterprise.getCreditCode()); + wrapper.orderByAsc(Enterprise::getName); + + final Page page = new Page<>(enterprise.getPage(), enterprise.getLimit()); + final IPage p = enterpriseService.page(page, wrapper); + + return success(new PageResult(p.getRecords(), p.getTotal())); + } + +// @PreAuthorize("hasAuthority('enterprise:enterprise:list')") + @Operation(summary = "查询全部企业信息") + @GetMapping("/list") + public ApiResult> list(Enterprise enterprise) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.like(StrUtil.isNotBlank(enterprise.getName()), Enterprise::getName, enterprise.getName()); + wrapper.like(StrUtil.isNotBlank(enterprise.getCreditCode()), Enterprise::getCreditCode, enterprise.getCreditCode()); + return success(enterpriseService.list(wrapper)); + } + +// @PreAuthorize("hasAuthority('enterprise:enterprise:list')") + @Operation(summary = "根据id查询企业信息") + @GetMapping("/{id}") + public ApiResult get(@PathVariable("id") Integer id) { + return success(enterpriseService.getById(id)); + } + +// @PreAuthorize("hasAuthority('enterprise:enterprise:list')") + @Operation(summary = "根据CreditCode查询企业信息") + @GetMapping("/creditCode/{creditCode}") + public ApiResult getByCreditCode(@PathVariable("creditCode") String creditCode) { + Enterprise enterprise = enterpriseService.getOne(new LambdaQueryWrapper().eq(Enterprise::getCreditCode, creditCode)); + return success(enterprise); + } + +// @PreAuthorize("hasAuthority('enterprise:enterprise:save')") + @OperationLog + @Operation(summary = "添加企业信息") + @PostMapping() + public ApiResult save(@RequestBody Enterprise enterprise) { + // 记录当前登录用户id + User loginUser = getLoginUser(); + if (loginUser != null) { + enterprise.setUserId(loginUser.getUserId()); + final Enterprise one = enterpriseService.getOne(new LambdaQueryWrapper().eq(Enterprise::getCreditCode, enterprise.getCreditCode())); + if (!ObjectUtil.isEmpty(one)) { + return fail("企业统一信用代码已存在"); + } + if (enterpriseService.save(enterprise)) { + //TODO 查询知识库(kb_name=enterprise.getCreditCode) + + //TODO 新建知识库 + String kbId = "pggi9mpair"; + + //绑定知识库 + enterprise.setKbId(kbId); + enterpriseService.updateById(enterprise); + return success("添加成功"); + } + } + return fail("添加失败"); + } + +// @PreAuthorize("hasAuthority('enterprise:enterprise:update')") + @OperationLog + @Operation(summary = "修改企业信息") + @PutMapping() + public ApiResult update(@RequestBody Enterprise enterprise) { + if(StrUtil.isEmpty(enterprise.getKbId())) { + //TODO 查询知识库 + + //TODO 新建知识库 + String kbId = "pggi9mpair"; + + //绑定知识库 + enterprise.setKbId(kbId); + } + if (enterpriseService.updateById(enterprise)) { + return success("修改成功"); + } + return fail("修改失败"); + } + +// @PreAuthorize("hasAuthority('enterprise:enterprise:remove')") + @OperationLog + @Operation(summary = "删除企业信息") + @DeleteMapping("/{id}") + public ApiResult remove(@PathVariable("id") Integer id) { + if (enterpriseService.removeById(id)) { + return success("删除成功"); + } + return fail("删除失败"); + } + +// @PreAuthorize("hasAuthority('enterprise:enterprise:remove')") + @OperationLog + @Operation(summary = "批量删除企业信息") + @DeleteMapping("/batch") + public ApiResult removeBatch(@RequestBody List ids) { + if (enterpriseService.removeByIds(ids)) { + return success("删除成功"); + } + return fail("删除失败"); + } + +} diff --git a/src/main/java/com/gxwebsoft/enterprise/entity/Enterprise.java b/src/main/java/com/gxwebsoft/enterprise/entity/Enterprise.java new file mode 100644 index 0000000..9e99efd --- /dev/null +++ b/src/main/java/com/gxwebsoft/enterprise/entity/Enterprise.java @@ -0,0 +1,67 @@ +package com.gxwebsoft.enterprise.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.gxwebsoft.common.core.web.BaseParam; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 企业信息 + * @author GIIT-YC + */ +@Data +@TableName("enterprise") +@EqualsAndHashCode(callSuper = false) +@Schema(name = "Enterprise对象", description = "企业信息表") +public class Enterprise extends BaseParam implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "ID") + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + @Schema(description = "企业名称") + private String name; + + @Schema(description = "统一代码") + private String creditCode; + + @Schema(description = "企业性质(国企、行政事业单位、民间非营利组织)") + private String enterpriseType; + + @Schema(description = "所属行业(使用插件)") + private String industry; + + @Schema(description = "知识库ID") + private String kbId; + + @Schema(description = "客户ID") + private Integer userId; + + @Schema(description = "租户ID") + private Integer tenantId; + + @Schema(description = "创建时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; + + @Schema(description = "修改时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updateTime; + + @Schema(description = "状态, 0正常, 1冻结") + private Integer status; + + @Schema(description = "是否删除, 0否, 1是") + @TableLogic + private Integer deleted; +} \ No newline at end of file diff --git a/src/main/java/com/gxwebsoft/enterprise/mapper/EnterpriseMapper.java b/src/main/java/com/gxwebsoft/enterprise/mapper/EnterpriseMapper.java new file mode 100644 index 0000000..33ca945 --- /dev/null +++ b/src/main/java/com/gxwebsoft/enterprise/mapper/EnterpriseMapper.java @@ -0,0 +1,13 @@ +package com.gxwebsoft.enterprise.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.enterprise.entity.Enterprise; + +/** + * + * @author GIIT-YC + * + */ +public interface EnterpriseMapper extends BaseMapper { + +} diff --git a/src/main/java/com/gxwebsoft/enterprise/mapper/xml/EnterpriseMapper.xml b/src/main/java/com/gxwebsoft/enterprise/mapper/xml/EnterpriseMapper.xml new file mode 100644 index 0000000..b543162 --- /dev/null +++ b/src/main/java/com/gxwebsoft/enterprise/mapper/xml/EnterpriseMapper.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/main/java/com/gxwebsoft/enterprise/service/EnterpriseService.java b/src/main/java/com/gxwebsoft/enterprise/service/EnterpriseService.java new file mode 100644 index 0000000..daf3ff8 --- /dev/null +++ b/src/main/java/com/gxwebsoft/enterprise/service/EnterpriseService.java @@ -0,0 +1,13 @@ +package com.gxwebsoft.enterprise.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.gxwebsoft.enterprise.entity.Enterprise; + +/** + * + * @author GIIT-YC + * + */ +public interface EnterpriseService extends IService { + +} diff --git a/src/main/java/com/gxwebsoft/enterprise/service/impl/EnterpriseServiceImpl.java b/src/main/java/com/gxwebsoft/enterprise/service/impl/EnterpriseServiceImpl.java new file mode 100644 index 0000000..a6eedda --- /dev/null +++ b/src/main/java/com/gxwebsoft/enterprise/service/impl/EnterpriseServiceImpl.java @@ -0,0 +1,17 @@ +package com.gxwebsoft.enterprise.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.gxwebsoft.enterprise.entity.Enterprise; +import com.gxwebsoft.enterprise.mapper.EnterpriseMapper; +import com.gxwebsoft.enterprise.service.EnterpriseService; +import org.springframework.stereotype.Service; + +/** + * + * @author GIIT-YC + * + */ +@Service +public class EnterpriseServiceImpl extends ServiceImpl implements EnterpriseService { + +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index d0f9922..057cb94 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -63,3 +63,16 @@ certificate: private-key-file: "apiclient_key.pem" apiclient-cert-file: "apiclient_cert.pem" wechatpay-cert-file: "wechatpay_cert.pem" + +aliyun: + knowledge-base: + access-key-id: LTAI5tD5YRKuxWz6Eg7qrM4P + access-key-secret: bO8TBDXflOwbtSKimPpG8XrJnyzgTk + workspace-id: llm-4pf5auwewoz34zqu + +ai: + template: + # Word 模板路径 + word-template-path: classpath:templates/audit_report_template.docx + # 或者使用绝对路径 + # word-template-path: D:\\公司经济责任审计方案模板.docx \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index fbff897..c27504d 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -69,3 +69,16 @@ payment: key-prefix: "Payment:1" # 缓存过期时间(小时) expire-hours: 24 + +aliyun: + knowledge-base: + access-key-id: LTAI5tD5YRKuxWz6Eg7qrM4P + access-key-secret: bO8TBDXflOwbtSKimPpG8XrJnyzgTk + workspace-id: llm-4pf5auwewoz34zqu + +ai: + template: + # Word 模板路径 + word-template-path: classpath:templates/audit_report_template.docx + # 或者使用绝对路径 + # word-template-path: D:\\公司经济责任审计方案模板.docx \ No newline at end of file diff --git a/src/main/resources/templates/audit_report_template.docx b/src/main/resources/templates/audit_report_template.docx new file mode 100644 index 0000000..0608b9d Binary files /dev/null and b/src/main/resources/templates/audit_report_template.docx differ