feat: 增加实训 AI 知识库解析
parent
4fd2b43133
commit
8a738c3105
@ -0,0 +1,40 @@
|
||||
CREATE TABLE IF NOT EXISTS training_knowledge_file (
|
||||
id varchar(64) NOT NULL,
|
||||
source_type varchar(32) NOT NULL COMMENT 'CASE/STUDENT_UPLOAD',
|
||||
config_scope varchar(32) NOT NULL COMMENT 'ADMIN/TEACHER/STUDENT',
|
||||
effective_config_id varchar(64) NOT NULL,
|
||||
task_id varchar(64) NOT NULL,
|
||||
task_key varchar(128) NOT NULL,
|
||||
teaching_class_id varchar(64) NULL,
|
||||
student_user_id varchar(64) NULL,
|
||||
step_no int NULL,
|
||||
file_name varchar(512) NOT NULL,
|
||||
file_url varchar(1024) NOT NULL,
|
||||
content_hash varchar(128) NULL,
|
||||
parse_status varchar(32) NOT NULL COMMENT 'SUCCESS/PENDING_VISION/EMPTY/FAILED',
|
||||
parse_message varchar(1000) NULL,
|
||||
section_count int NOT NULL DEFAULT 0,
|
||||
create_time datetime NOT NULL,
|
||||
update_time datetime NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_training_knowledge_file_scope (source_type, effective_config_id, student_user_id),
|
||||
KEY idx_training_knowledge_file_task (task_key, teaching_class_id, student_user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS training_knowledge_chunk (
|
||||
id varchar(64) NOT NULL,
|
||||
knowledge_file_id varchar(64) NOT NULL,
|
||||
task_key varchar(128) NOT NULL,
|
||||
effective_config_id varchar(64) NOT NULL,
|
||||
teaching_class_id varchar(64) NULL,
|
||||
student_user_id varchar(64) NULL,
|
||||
step_no int NULL,
|
||||
section_type varchar(32) NOT NULL,
|
||||
section_name varchar(512) NULL,
|
||||
section_sequence int NOT NULL,
|
||||
content longtext NOT NULL,
|
||||
create_time datetime NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_training_knowledge_chunk_file (knowledge_file_id),
|
||||
KEY idx_training_knowledge_chunk_scope (task_key, effective_config_id, teaching_class_id, student_user_id, step_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@ -0,0 +1,148 @@
|
||||
package com.sztzjy.linkCommerce.ai;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.sztzjy.linkCommerce.config.ai.QwenProperties;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Uses an OpenAI-compatible Qwen-VL endpoint to transcribe uploaded images and scanned PDFs.
|
||||
* It is deliberately limited to small images / the first PDF pages to protect request latency and cost.
|
||||
*/
|
||||
@Component
|
||||
public class QwenVisionTextExtractor {
|
||||
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
|
||||
private static final int MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
||||
private static final int MAX_PDF_PAGES = 6;
|
||||
private static final int MAX_DIMENSION = 1600;
|
||||
private final QwenProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
public QwenVisionTextExtractor(QwenProperties properties) {
|
||||
this(properties, new ObjectMapper());
|
||||
}
|
||||
|
||||
QwenVisionTextExtractor(QwenProperties properties, ObjectMapper objectMapper) {
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public boolean isAvailable() {
|
||||
return StringUtils.isNotBlank(properties.getApiKey()) && StringUtils.isNotBlank(properties.getBaseUrl())
|
||||
&& StringUtils.isNotBlank(properties.getVisionModel());
|
||||
}
|
||||
|
||||
public String extract(Path file) throws IOException {
|
||||
if (!isAvailable() || file == null || !Files.isRegularFile(file)) return "";
|
||||
String extension = extension(file.getFileName().toString());
|
||||
if ("pdf".equals(extension)) return scannedPdf(file);
|
||||
return transcribe(dataUrl(Files.readAllBytes(file), mimeType(extension)));
|
||||
}
|
||||
|
||||
String requestJson(Path file) throws IOException {
|
||||
return requestJson(dataUrl(Files.readAllBytes(file), mimeType(extension(file.getFileName().toString()))));
|
||||
}
|
||||
|
||||
private String scannedPdf(Path file) throws IOException {
|
||||
List<String> pages = new ArrayList<>();
|
||||
try (PDDocument document = PDDocument.load(file.toFile())) {
|
||||
PDFRenderer renderer = new PDFRenderer(document);
|
||||
int limit = Math.min(document.getNumberOfPages(), MAX_PDF_PAGES);
|
||||
for (int page = 0; page < limit; page++) {
|
||||
BufferedImage image = renderer.renderImageWithDPI(page, 144);
|
||||
String text = transcribe(dataUrl(toPng(scale(image)), "image/png"));
|
||||
if (StringUtils.isNotBlank(text)) pages.add("第" + (page + 1) + "页:" + text);
|
||||
}
|
||||
}
|
||||
return StringUtils.join(pages, "\n");
|
||||
}
|
||||
|
||||
private String transcribe(String dataUrl) throws IOException {
|
||||
Request request = new Request.Builder().url(endpoint())
|
||||
.header("Authorization", "Bearer " + properties.getApiKey().trim())
|
||||
.header("Content-Type", "application/json")
|
||||
.post(RequestBody.create(requestJson(dataUrl), JSON)).build();
|
||||
try (Response response = httpClient().newCall(request).execute()) {
|
||||
if (!response.isSuccessful() || response.body() == null) throw new IOException("Qwen vision request failed");
|
||||
JsonNode root = objectMapper.readTree(response.body().string());
|
||||
JsonNode content = root.path("choices").path(0).path("message").path("content");
|
||||
return content.isTextual() ? content.asText().trim() : "";
|
||||
}
|
||||
}
|
||||
|
||||
private String requestJson(String dataUrl) throws IOException {
|
||||
ObjectNode request = objectMapper.createObjectNode();
|
||||
request.put("model", properties.getVisionModel().trim());
|
||||
request.put("temperature", 0D);
|
||||
ArrayNode messages = request.putArray("messages");
|
||||
ArrayNode content = messages.addObject().put("role", "user").putArray("content");
|
||||
content.addObject().put("type", "text").put("text", "请准确提取图片或扫描页中与实训相关的全部文字、表格标题和关键数值。只返回提取结果,不要解释。");
|
||||
content.addObject().put("type", "image_url").putObject("image_url").put("url", dataUrl);
|
||||
return objectMapper.writeValueAsString(request);
|
||||
}
|
||||
|
||||
private String endpoint() { return properties.getBaseUrl().trim().replaceAll("/+$", "") + "/chat/completions"; }
|
||||
|
||||
private OkHttpClient httpClient() {
|
||||
long timeout = properties.getTimeoutMillis() > 0 ? properties.getTimeoutMillis() : 60_000L;
|
||||
return new OkHttpClient.Builder().connectTimeout(timeout, TimeUnit.MILLISECONDS).readTimeout(timeout, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(timeout, TimeUnit.MILLISECONDS).callTimeout(timeout, TimeUnit.MILLISECONDS).build();
|
||||
}
|
||||
|
||||
private String dataUrl(byte[] bytes, String mime) throws IOException {
|
||||
if (bytes.length > MAX_IMAGE_BYTES) throw new IOException("视觉识别图片超过 4MB 限制");
|
||||
return "data:" + mime + ";base64," + Base64.getEncoder().encodeToString(bytes);
|
||||
}
|
||||
|
||||
private byte[] toPng(BufferedImage image) throws IOException {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, "png", output);
|
||||
return output.toByteArray();
|
||||
}
|
||||
|
||||
private BufferedImage scale(BufferedImage source) {
|
||||
int max = Math.max(source.getWidth(), source.getHeight());
|
||||
if (max <= MAX_DIMENSION) return source;
|
||||
double ratio = (double) MAX_DIMENSION / max;
|
||||
BufferedImage target = new BufferedImage((int) (source.getWidth() * ratio), (int) (source.getHeight() * ratio), BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D graphics = target.createGraphics();
|
||||
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||
graphics.drawImage(source, 0, 0, target.getWidth(), target.getHeight(), null); graphics.dispose();
|
||||
return target;
|
||||
}
|
||||
|
||||
private String extension(String name) { int dot = name.lastIndexOf('.'); return dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT); }
|
||||
private String mimeType(String extension) {
|
||||
if ("jpg".equals(extension) || "jpeg".equals(extension)) return "image/jpeg";
|
||||
if ("webp".equals(extension)) return "image/webp";
|
||||
if ("gif".equals(extension)) return "image/gif";
|
||||
if ("bmp".equals(extension)) return "image/bmp";
|
||||
return "image/png";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package com.sztzjy.linkCommerce.entity;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
public class TrainingKnowledgeChunk {
|
||||
private String id;
|
||||
private String knowledgeFileId;
|
||||
private String taskKey;
|
||||
private String effectiveConfigId;
|
||||
private String teachingClassId;
|
||||
private String studentUserId;
|
||||
private Integer stepNo;
|
||||
private String sectionType;
|
||||
private String sectionName;
|
||||
private Integer sectionSequence;
|
||||
private String content;
|
||||
private Date createTime;
|
||||
public String getId() { return id; } public void setId(String v) { id = v; }
|
||||
public String getKnowledgeFileId() { return knowledgeFileId; } public void setKnowledgeFileId(String v) { knowledgeFileId = v; }
|
||||
public String getTaskKey() { return taskKey; } public void setTaskKey(String v) { taskKey = v; }
|
||||
public String getEffectiveConfigId() { return effectiveConfigId; } public void setEffectiveConfigId(String v) { effectiveConfigId = v; }
|
||||
public String getTeachingClassId() { return teachingClassId; } public void setTeachingClassId(String v) { teachingClassId = v; }
|
||||
public String getStudentUserId() { return studentUserId; } public void setStudentUserId(String v) { studentUserId = v; }
|
||||
public Integer getStepNo() { return stepNo; } public void setStepNo(Integer v) { stepNo = v; }
|
||||
public String getSectionType() { return sectionType; } public void setSectionType(String v) { sectionType = v; }
|
||||
public String getSectionName() { return sectionName; } public void setSectionName(String v) { sectionName = v; }
|
||||
public Integer getSectionSequence() { return sectionSequence; } public void setSectionSequence(Integer v) { sectionSequence = v; }
|
||||
public String getContent() { return content; } public void setContent(String v) { content = v; }
|
||||
public Date getCreateTime() { return createTime; } public void setCreateTime(Date v) { createTime = v; }
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package com.sztzjy.linkCommerce.entity;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
public class TrainingKnowledgeFile {
|
||||
private String id;
|
||||
private String sourceType;
|
||||
private String configScope;
|
||||
private String effectiveConfigId;
|
||||
private String taskId;
|
||||
private String taskKey;
|
||||
private String teachingClassId;
|
||||
private String studentUserId;
|
||||
private Integer stepNo;
|
||||
private String fileName;
|
||||
private String fileUrl;
|
||||
private String contentHash;
|
||||
private String parseStatus;
|
||||
private String parseMessage;
|
||||
private Integer sectionCount;
|
||||
private Date createTime;
|
||||
private Date updateTime;
|
||||
public String getId() { return id; } public void setId(String v) { id = v; }
|
||||
public String getSourceType() { return sourceType; } public void setSourceType(String v) { sourceType = v; }
|
||||
public String getConfigScope() { return configScope; } public void setConfigScope(String v) { configScope = v; }
|
||||
public String getEffectiveConfigId() { return effectiveConfigId; } public void setEffectiveConfigId(String v) { effectiveConfigId = v; }
|
||||
public String getTaskId() { return taskId; } public void setTaskId(String v) { taskId = v; }
|
||||
public String getTaskKey() { return taskKey; } public void setTaskKey(String v) { taskKey = v; }
|
||||
public String getTeachingClassId() { return teachingClassId; } public void setTeachingClassId(String v) { teachingClassId = v; }
|
||||
public String getStudentUserId() { return studentUserId; } public void setStudentUserId(String v) { studentUserId = v; }
|
||||
public Integer getStepNo() { return stepNo; } public void setStepNo(Integer v) { stepNo = v; }
|
||||
public String getFileName() { return fileName; } public void setFileName(String v) { fileName = v; }
|
||||
public String getFileUrl() { return fileUrl; } public void setFileUrl(String v) { fileUrl = v; }
|
||||
public String getContentHash() { return contentHash; } public void setContentHash(String v) { contentHash = v; }
|
||||
public String getParseStatus() { return parseStatus; } public void setParseStatus(String v) { parseStatus = v; }
|
||||
public String getParseMessage() { return parseMessage; } public void setParseMessage(String v) { parseMessage = v; }
|
||||
public Integer getSectionCount() { return sectionCount; } public void setSectionCount(Integer v) { sectionCount = v; }
|
||||
public Date getCreateTime() { return createTime; } public void setCreateTime(Date v) { createTime = v; }
|
||||
public Date getUpdateTime() { return updateTime; } public void setUpdateTime(Date v) { updateTime = v; }
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package com.sztzjy.linkCommerce.mapper;
|
||||
|
||||
import com.sztzjy.linkCommerce.entity.TrainingKnowledgeChunk;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface TrainingKnowledgeChunkMapper {
|
||||
int deleteByKnowledgeFileId(@Param("knowledgeFileId") String knowledgeFileId);
|
||||
int insertBatch(@Param("items") List<TrainingKnowledgeChunk> items);
|
||||
List<TrainingKnowledgeChunk> selectForAi(@Param("taskKey") String taskKey,
|
||||
@Param("effectiveConfigId") String effectiveConfigId,
|
||||
@Param("teachingClassId") String teachingClassId,
|
||||
@Param("studentUserId") String studentUserId,
|
||||
@Param("limit") int limit);
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package com.sztzjy.linkCommerce.mapper;
|
||||
|
||||
import com.sztzjy.linkCommerce.entity.TrainingKnowledgeFile;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@Mapper
|
||||
public interface TrainingKnowledgeFileMapper {
|
||||
TrainingKnowledgeFile selectBySourceScopeAndUrl(@Param("sourceType") String sourceType,
|
||||
@Param("effectiveConfigId") String effectiveConfigId,
|
||||
@Param("studentUserId") String studentUserId,
|
||||
@Param("fileUrl") String fileUrl);
|
||||
int insert(TrainingKnowledgeFile record);
|
||||
int update(TrainingKnowledgeFile record);
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package com.sztzjy.linkCommerce.service;
|
||||
|
||||
import com.sztzjy.linkCommerce.entity.StudentTrainingAnswer;
|
||||
import com.sztzjy.linkCommerce.entity.TrainingTask;
|
||||
import com.sztzjy.linkCommerce.training.knowledge.EffectiveTrainingTaskContent;
|
||||
|
||||
public interface TrainingKnowledgeService {
|
||||
String prepareContext(TrainingTask task, EffectiveTrainingTaskContent effectiveContent,
|
||||
String teachingClassId, String studentUserId,
|
||||
StudentTrainingAnswer answer, String query);
|
||||
|
||||
/**
|
||||
* Indexes attachments after a student has saved the answer that references them.
|
||||
* The answer save is the first point where an otherwise generic uploaded file has
|
||||
* a reliable student, class and training-task scope.
|
||||
*/
|
||||
void indexStudentAnswer(TrainingTask task, EffectiveTrainingTaskContent effectiveContent,
|
||||
String teachingClassId, String studentUserId,
|
||||
StudentTrainingAnswer answer);
|
||||
|
||||
/** Indexes the currently effective administrator or teacher case material. */
|
||||
void indexCaseMaterial(TrainingTask task, EffectiveTrainingTaskContent effectiveContent,
|
||||
String teachingClassId);
|
||||
}
|
||||
@ -0,0 +1,204 @@
|
||||
package com.sztzjy.linkCommerce.service.impl;
|
||||
|
||||
import com.sztzjy.linkCommerce.entity.StudentTrainingAnswer;
|
||||
import com.sztzjy.linkCommerce.entity.TrainingKnowledgeChunk;
|
||||
import com.sztzjy.linkCommerce.entity.TrainingKnowledgeFile;
|
||||
import com.sztzjy.linkCommerce.entity.TrainingTask;
|
||||
import com.sztzjy.linkCommerce.ai.QwenVisionTextExtractor;
|
||||
import com.sztzjy.linkCommerce.mapper.TrainingKnowledgeChunkMapper;
|
||||
import com.sztzjy.linkCommerce.mapper.TrainingKnowledgeFileMapper;
|
||||
import com.sztzjy.linkCommerce.service.TrainingKnowledgeService;
|
||||
import com.sztzjy.linkCommerce.training.knowledge.EffectiveTrainingTaskContent;
|
||||
import com.sztzjy.linkCommerce.training.knowledge.TrainingKnowledgeChunkRanker;
|
||||
import com.sztzjy.linkCommerce.training.knowledge.TrainingDocumentTextExtractor;
|
||||
import com.sztzjy.linkCommerce.util.file.IFileUtil;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Service
|
||||
public class TrainingKnowledgeServiceImpl implements TrainingKnowledgeService {
|
||||
private static final String CASE = "CASE";
|
||||
private static final String STUDENT_UPLOAD = "STUDENT_UPLOAD";
|
||||
private static final Pattern FILE_URL = Pattern.compile("(?:https?://[^\\s\\\"']+)?/file/[^\\s\\\"']+", Pattern.CASE_INSENSITIVE);
|
||||
private static final int MAX_CHUNKS = 12;
|
||||
private static final int MAX_CONTEXT_LENGTH = 18000;
|
||||
|
||||
@Autowired private TrainingKnowledgeFileMapper knowledgeFileMapper;
|
||||
@Autowired private TrainingKnowledgeChunkMapper knowledgeChunkMapper;
|
||||
@Autowired private IFileUtil fileUtil;
|
||||
@Autowired(required = false) private QwenVisionTextExtractor visionTextExtractor;
|
||||
|
||||
private final TrainingDocumentTextExtractor extractor = new TrainingDocumentTextExtractor();
|
||||
|
||||
@Override
|
||||
public String prepareContext(TrainingTask task, EffectiveTrainingTaskContent effectiveContent,
|
||||
String teachingClassId, String studentUserId,
|
||||
StudentTrainingAnswer answer, String query) {
|
||||
indexCaseMaterial(task, effectiveContent, teachingClassId);
|
||||
indexStudentUploads(task, effectiveContent, teachingClassId, studentUserId, answer);
|
||||
List<TrainingKnowledgeChunk> chunks = knowledgeChunkMapper.selectForAi(task.getTaskKey(),
|
||||
effectiveContent.getEffectiveConfigId(), teachingClassId, studentUserId, MAX_CHUNKS);
|
||||
return render(chunks, query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void indexCaseMaterial(TrainingTask task, EffectiveTrainingTaskContent content, String teachingClassId) {
|
||||
if (StringUtils.isBlank(content.getMaterialUrl())) return;
|
||||
index(task, content, CASE, content.getScope(), content.getScope().equals("TEACHER") ? teachingClassId : null,
|
||||
null, null, content.getMaterialName(), content.getMaterialUrl());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void indexStudentAnswer(TrainingTask task, EffectiveTrainingTaskContent content, String teachingClassId,
|
||||
String studentUserId, StudentTrainingAnswer answer) {
|
||||
indexStudentUploads(task, content, teachingClassId, studentUserId, answer);
|
||||
}
|
||||
|
||||
private void indexStudentUploads(TrainingTask task, EffectiveTrainingTaskContent content, String teachingClassId,
|
||||
String studentUserId, StudentTrainingAnswer answer) {
|
||||
if (answer == null) return;
|
||||
Map<String, Integer> urls = new LinkedHashMap<>();
|
||||
collectUrls(urls, answer.getStep1Answer(), 1);
|
||||
collectUrls(urls, answer.getStep2Answer(), 2);
|
||||
collectUrls(urls, answer.getStep3Answer(), 3);
|
||||
collectUrls(urls, answer.getStep4Answer(), 4);
|
||||
collectUrls(urls, answer.getDynamicStepAnswers(), null);
|
||||
for (Map.Entry<String, Integer> item : urls.entrySet()) {
|
||||
index(task, content, STUDENT_UPLOAD, "STUDENT", teachingClassId, studentUserId,
|
||||
item.getValue(), fileName(item.getKey()), item.getKey());
|
||||
}
|
||||
}
|
||||
|
||||
private void collectUrls(Map<String, Integer> urls, String answer, Integer stepNo) {
|
||||
if (StringUtils.isBlank(answer)) return;
|
||||
Matcher matcher = FILE_URL.matcher(answer.replace("\\/", "/"));
|
||||
while (matcher.find()) urls.putIfAbsent(matcher.group(), stepNo);
|
||||
}
|
||||
|
||||
private void index(TrainingTask task, EffectiveTrainingTaskContent content, String sourceType, String scope,
|
||||
String teachingClassId, String studentUserId, Integer stepNo, String fileName, String fileUrl) {
|
||||
try {
|
||||
Path path = localFile(fileUrl);
|
||||
if (path == null || !Files.isRegularFile(path)) return;
|
||||
String hash = sha256(path);
|
||||
TrainingKnowledgeFile existing = knowledgeFileMapper.selectBySourceScopeAndUrl(sourceType,
|
||||
content.getEffectiveConfigId(), studentUserId, fileUrl);
|
||||
if (existing != null && hash.equals(existing.getContentHash()) && "SUCCESS".equals(existing.getParseStatus())) return;
|
||||
|
||||
List<TrainingDocumentTextExtractor.Section> sections = extractor.extract(path, fileName);
|
||||
String parseMessage = null;
|
||||
if (sections.isEmpty() && extractor.requiresVision(fileName) && visionTextExtractor != null && visionTextExtractor.isAvailable()) {
|
||||
try {
|
||||
String visualText = visionTextExtractor.extract(path);
|
||||
if (StringUtils.isNotBlank(visualText)) {
|
||||
sections = java.util.Collections.singletonList(new TrainingDocumentTextExtractor.Section("vision", "视觉识别结果", 1, visualText));
|
||||
} else parseMessage = "视觉解析未识别到文字,等待下次重试";
|
||||
} catch (Exception ignored) {
|
||||
parseMessage = "视觉解析暂不可用,等待下次重试";
|
||||
}
|
||||
}
|
||||
String status = sections.isEmpty() ? (extractor.requiresVision(fileName) ? "PENDING_VISION" : "EMPTY") : "SUCCESS";
|
||||
TrainingKnowledgeFile record = existing == null ? new TrainingKnowledgeFile() : existing;
|
||||
Date now = new Date();
|
||||
if (existing == null) {
|
||||
record.setId(UUID.randomUUID().toString());
|
||||
record.setSourceType(sourceType); record.setConfigScope(scope); record.setEffectiveConfigId(content.getEffectiveConfigId());
|
||||
record.setTaskId(task.getId()); record.setTaskKey(task.getTaskKey()); record.setTeachingClassId(teachingClassId);
|
||||
record.setStudentUserId(studentUserId); record.setStepNo(stepNo); record.setFileUrl(fileUrl); record.setCreateTime(now);
|
||||
}
|
||||
record.setFileName(StringUtils.defaultIfBlank(fileName, "附件")); record.setContentHash(hash);
|
||||
record.setParseStatus(status);
|
||||
record.setParseMessage("PENDING_VISION".equals(status)
|
||||
? StringUtils.defaultIfBlank(parseMessage, "图片或扫描件待视觉解析") : null);
|
||||
record.setSectionCount(sections.size()); record.setUpdateTime(now);
|
||||
if (existing == null) knowledgeFileMapper.insert(record); else knowledgeFileMapper.update(record);
|
||||
knowledgeChunkMapper.deleteByKnowledgeFileId(record.getId());
|
||||
if (!sections.isEmpty()) knowledgeChunkMapper.insertBatch(chunks(record, content, sections, now));
|
||||
} catch (Exception ignored) {
|
||||
// A malformed attachment must not block AI help or assessment. The next request can retry indexing.
|
||||
}
|
||||
}
|
||||
|
||||
private List<TrainingKnowledgeChunk> chunks(TrainingKnowledgeFile file, EffectiveTrainingTaskContent content,
|
||||
List<TrainingDocumentTextExtractor.Section> sections, Date now) {
|
||||
List<TrainingKnowledgeChunk> result = new ArrayList<>();
|
||||
for (TrainingDocumentTextExtractor.Section section : sections) {
|
||||
TrainingKnowledgeChunk chunk = new TrainingKnowledgeChunk();
|
||||
chunk.setId(UUID.randomUUID().toString()); chunk.setKnowledgeFileId(file.getId()); chunk.setTaskKey(file.getTaskKey());
|
||||
chunk.setEffectiveConfigId(content.getEffectiveConfigId()); chunk.setTeachingClassId(file.getTeachingClassId());
|
||||
chunk.setStudentUserId(file.getStudentUserId()); chunk.setStepNo(file.getStepNo()); chunk.setSectionType(section.getSectionType());
|
||||
chunk.setSectionName(section.getSectionName()); chunk.setSectionSequence(section.getSequence()); chunk.setContent(section.getText()); chunk.setCreateTime(now);
|
||||
result.add(chunk);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Path localFile(String fileUrl) {
|
||||
if (StringUtils.isBlank(fileUrl)) return null;
|
||||
String relative = fileUrl.replace("\\", "/");
|
||||
if (relative.startsWith("http://") || relative.startsWith("https://")) relative = URI.create(relative).getPath();
|
||||
int start = relative.indexOf("/file/");
|
||||
if (start >= 0) relative = relative.substring(start + 1);
|
||||
try {
|
||||
relative = URLDecoder.decode(relative, StandardCharsets.UTF_8.name());
|
||||
} catch (Exception ignored) {
|
||||
// Keep the original URL path if it is not percent encoded correctly.
|
||||
}
|
||||
return java.nio.file.Paths.get(fileUtil.getFullPath(relative));
|
||||
}
|
||||
|
||||
private String sha256(Path path) throws Exception {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
try (InputStream input = Files.newInputStream(path)) {
|
||||
byte[] buffer = new byte[8192]; int read;
|
||||
while ((read = input.read(buffer)) >= 0) digest.update(buffer, 0, read);
|
||||
}
|
||||
StringBuilder value = new StringBuilder();
|
||||
for (byte b : digest.digest()) value.append(String.format("%02x", b));
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
private String render(List<TrainingKnowledgeChunk> chunks, String query) {
|
||||
if (chunks == null || chunks.isEmpty()) return "";
|
||||
List<String> sourceTexts = new ArrayList<>();
|
||||
Map<String, List<TrainingKnowledgeChunk>> byContent = new LinkedHashMap<>();
|
||||
for (TrainingKnowledgeChunk chunk : chunks) {
|
||||
if (StringUtils.isBlank(chunk.getContent())) continue;
|
||||
sourceTexts.add(chunk.getContent());
|
||||
byContent.computeIfAbsent(chunk.getContent(), ignored -> new ArrayList<>()).add(chunk);
|
||||
}
|
||||
StringBuilder context = new StringBuilder();
|
||||
for (String text : TrainingKnowledgeChunkRanker.rank(sourceTexts, query)) {
|
||||
List<TrainingKnowledgeChunk> matches = byContent.get(text);
|
||||
if (matches == null || matches.isEmpty()) continue;
|
||||
TrainingKnowledgeChunk chunk = matches.remove(0);
|
||||
String header = "[" + StringUtils.defaultIfBlank(chunk.getSectionName(), "附件") + "] ";
|
||||
if (context.length() + header.length() + text.length() > MAX_CONTEXT_LENGTH) break;
|
||||
context.append(header).append(text).append('\n');
|
||||
}
|
||||
return context.toString();
|
||||
}
|
||||
|
||||
private String fileName(String url) {
|
||||
int index = url.lastIndexOf('/');
|
||||
return index >= 0 ? url.substring(index + 1) : url;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
package com.sztzjy.linkCommerce.training.knowledge;
|
||||
|
||||
import com.sztzjy.linkCommerce.entity.TrainingTask;
|
||||
import com.sztzjy.linkCommerce.entity.TrainingTaskClassConfig;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
/** Effective task data for a student: enabled teacher configuration overrides the admin default field by field. */
|
||||
public class EffectiveTrainingTaskContent {
|
||||
private final String scope;
|
||||
private final String effectiveConfigId;
|
||||
private final String knowledge;
|
||||
private final String background;
|
||||
private final String objectives;
|
||||
private final String requirements;
|
||||
private final String steps;
|
||||
private final String materialName;
|
||||
private final String materialUrl;
|
||||
|
||||
private EffectiveTrainingTaskContent(String scope, String effectiveConfigId, String knowledge, String background,
|
||||
String objectives, String requirements, String steps, String materialName,
|
||||
String materialUrl) {
|
||||
this.scope = scope;
|
||||
this.effectiveConfigId = effectiveConfigId;
|
||||
this.knowledge = knowledge;
|
||||
this.background = background;
|
||||
this.objectives = objectives;
|
||||
this.requirements = requirements;
|
||||
this.steps = steps;
|
||||
this.materialName = materialName;
|
||||
this.materialUrl = materialUrl;
|
||||
}
|
||||
|
||||
public static EffectiveTrainingTaskContent resolve(TrainingTask task, TrainingTaskClassConfig classConfig) {
|
||||
boolean useTeacher = classConfig != null && Boolean.TRUE.equals(classConfig.getEnabled())
|
||||
&& StringUtils.isNotBlank(classConfig.getId());
|
||||
return new EffectiveTrainingTaskContent(
|
||||
useTeacher ? "TEACHER" : "ADMIN",
|
||||
useTeacher ? classConfig.getId() : task.getId(),
|
||||
value(useTeacher ? classConfig.getKnowledge() : null, task.getKnowledge()),
|
||||
value(useTeacher ? classConfig.getBackground() : null, task.getBackground()),
|
||||
value(useTeacher ? classConfig.getObjectives() : null, task.getObjectives()),
|
||||
value(useTeacher ? classConfig.getRequirements() : null, task.getRequirements()),
|
||||
value(useTeacher ? classConfig.getSteps() : null, task.getSteps()),
|
||||
value(useTeacher ? classConfig.getMaterialName() : null, task.getMaterialName()),
|
||||
value(useTeacher ? classConfig.getMaterialUrl() : null, task.getMaterialUrl()));
|
||||
}
|
||||
|
||||
private static String value(String preferred, String fallback) {
|
||||
return StringUtils.isNotBlank(preferred) ? preferred : fallback;
|
||||
}
|
||||
|
||||
public String getScope() { return scope; }
|
||||
public String getEffectiveConfigId() { return effectiveConfigId; }
|
||||
public String getKnowledge() { return knowledge; }
|
||||
public String getBackground() { return background; }
|
||||
public String getObjectives() { return objectives; }
|
||||
public String getRequirements() { return requirements; }
|
||||
public String getSteps() { return steps; }
|
||||
public String getMaterialName() { return materialName; }
|
||||
public String getMaterialUrl() { return materialUrl; }
|
||||
}
|
||||
@ -0,0 +1,185 @@
|
||||
package com.sztzjy.linkCommerce.training.knowledge;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.apache.poi.hslf.usermodel.HSLFSlideShow;
|
||||
import org.apache.poi.hwpf.HWPFDocument;
|
||||
import org.apache.poi.hwpf.extractor.WordExtractor;
|
||||
import org.apache.poi.poifs.filesystem.POIFSFileSystem;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.DataFormatter;
|
||||
import org.apache.poi.ss.usermodel.FormulaEvaluator;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.apache.poi.xslf.usermodel.XSLFShape;
|
||||
import org.apache.poi.xslf.usermodel.XSLFSlide;
|
||||
import org.apache.poi.xslf.usermodel.XSLFSlideShow;
|
||||
import org.apache.poi.xslf.usermodel.XSLFTextShape;
|
||||
import org.apache.poi.xslf.usermodel.XMLSlideShow;
|
||||
import org.apache.poi.xwpf.extractor.XWPFWordExtractor;
|
||||
import org.apache.poi.xwpf.usermodel.XWPFDocument;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Extracts locally stored training documents into stable, source-labelled sections.
|
||||
* Image-only files intentionally return no text; callers can queue them for vision/OCR.
|
||||
*/
|
||||
public class TrainingDocumentTextExtractor {
|
||||
private static final int MAX_SECTION_LENGTH = 5000;
|
||||
|
||||
public List<Section> extract(Path file, String displayName) throws Exception {
|
||||
String extension = extension(displayName == null ? file.getFileName().toString() : displayName);
|
||||
if ("docx".equals(extension)) return wordDocx(file);
|
||||
if ("doc".equals(extension)) return wordDoc(file);
|
||||
if ("xlsx".equals(extension) || "xls".equals(extension) || "csv".equals(extension)) return workbook(file);
|
||||
if ("pptx".equals(extension)) return slidesPptx(file);
|
||||
if ("ppt".equals(extension)) return slidesPpt(file);
|
||||
if ("pdf".equals(extension)) return pdf(file);
|
||||
if ("txt".equals(extension) || "md".equals(extension) || "json".equals(extension) || "xml".equals(extension)) return plainText(file);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
public boolean requiresVision(String fileName) {
|
||||
String extension = extension(fileName);
|
||||
return "png".equals(extension) || "jpg".equals(extension) || "jpeg".equals(extension)
|
||||
|| "webp".equals(extension) || "gif".equals(extension) || "bmp".equals(extension) || "pdf".equals(extension);
|
||||
}
|
||||
|
||||
private List<Section> wordDocx(Path file) throws Exception {
|
||||
try (InputStream input = Files.newInputStream(file); XWPFDocument document = new XWPFDocument(input);
|
||||
XWPFWordExtractor extractor = new XWPFWordExtractor(document)) {
|
||||
return sections("document", "正文", extractor.getText());
|
||||
}
|
||||
}
|
||||
|
||||
private List<Section> wordDoc(Path file) throws Exception {
|
||||
try (InputStream input = Files.newInputStream(file); POIFSFileSystem filesystem = new POIFSFileSystem(input);
|
||||
HWPFDocument document = new HWPFDocument(filesystem); WordExtractor extractor = new WordExtractor(document)) {
|
||||
return sections("document", "正文", extractor.getText());
|
||||
}
|
||||
}
|
||||
|
||||
private List<Section> workbook(Path file) throws Exception {
|
||||
List<Section> result = new ArrayList<>();
|
||||
try (InputStream input = Files.newInputStream(file); Workbook workbook = WorkbookFactory.create(input)) {
|
||||
DataFormatter formatter = new DataFormatter(Locale.CHINA);
|
||||
FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
|
||||
for (int sheetIndex = 0; sheetIndex < workbook.getNumberOfSheets(); sheetIndex++) {
|
||||
Sheet sheet = workbook.getSheetAt(sheetIndex);
|
||||
StringBuilder text = new StringBuilder();
|
||||
int lastRow = Math.max(0, sheet.getLastRowNum());
|
||||
for (int rowIndex = 0; rowIndex <= lastRow; rowIndex++) {
|
||||
Row row = sheet.getRow(rowIndex);
|
||||
if (row == null) continue;
|
||||
int lastCell = Math.max(0, row.getLastCellNum());
|
||||
List<String> cells = new ArrayList<>();
|
||||
for (int cellIndex = 0; cellIndex < lastCell; cellIndex++) {
|
||||
Cell cell = row.getCell(cellIndex, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL);
|
||||
cells.add(cell == null ? "" : formatter.formatCellValue(cell, evaluator).trim());
|
||||
}
|
||||
String line = StringUtils.join(cells, " | ").trim();
|
||||
if (StringUtils.isNotBlank(line.replace("|", "").trim())) {
|
||||
text.append(line).append('\n');
|
||||
}
|
||||
}
|
||||
result.addAll(sections("sheet", sheet.getSheetName(), text.toString()));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<Section> slidesPptx(Path file) throws Exception {
|
||||
List<Section> result = new ArrayList<>();
|
||||
try (InputStream input = Files.newInputStream(file); XMLSlideShow show = new XMLSlideShow(input)) {
|
||||
int index = 1;
|
||||
for (XSLFSlide slide : show.getSlides()) {
|
||||
StringBuilder text = new StringBuilder();
|
||||
for (XSLFShape shape : slide.getShapes()) {
|
||||
if (shape instanceof XSLFTextShape) text.append(((XSLFTextShape) shape).getText()).append('\n');
|
||||
}
|
||||
result.addAll(sections("slide", "第" + index++ + "页", text.toString()));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<Section> slidesPpt(Path file) throws Exception {
|
||||
List<Section> result = new ArrayList<>();
|
||||
try (InputStream input = Files.newInputStream(file); HSLFSlideShow show = new HSLFSlideShow(input)) {
|
||||
int index = 1;
|
||||
for (org.apache.poi.hslf.usermodel.HSLFSlide slide : show.getSlides()) {
|
||||
StringBuilder text = new StringBuilder();
|
||||
for (org.apache.poi.hslf.usermodel.HSLFShape shape : slide.getShapes()) {
|
||||
if (shape instanceof org.apache.poi.hslf.usermodel.HSLFTextShape) {
|
||||
text.append(((org.apache.poi.hslf.usermodel.HSLFTextShape) shape).getText()).append('\n');
|
||||
}
|
||||
}
|
||||
result.addAll(sections("slide", "第" + index++ + "页", text.toString()));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<Section> pdf(Path file) throws Exception {
|
||||
List<Section> result = new ArrayList<>();
|
||||
try (PDDocument document = PDDocument.load(file.toFile())) {
|
||||
PDFTextStripper stripper = new PDFTextStripper();
|
||||
for (int page = 1; page <= document.getNumberOfPages(); page++) {
|
||||
stripper.setStartPage(page);
|
||||
stripper.setEndPage(page);
|
||||
result.addAll(sections("page", "第" + page + "页", stripper.getText(document)));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<Section> plainText(Path file) throws Exception {
|
||||
return sections("document", "正文", new String(Files.readAllBytes(file), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private List<Section> sections(String type, String name, String rawText) {
|
||||
String text = StringUtils.normalizeSpace(StringUtils.defaultString(rawText));
|
||||
if (StringUtils.isBlank(text)) return Collections.emptyList();
|
||||
List<Section> result = new ArrayList<>();
|
||||
int part = 1;
|
||||
for (int start = 0; start < text.length(); start += MAX_SECTION_LENGTH) {
|
||||
int end = Math.min(text.length(), start + MAX_SECTION_LENGTH);
|
||||
result.add(new Section(type, name, part++, text.substring(start, end)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String extension(String fileName) {
|
||||
if (StringUtils.isBlank(fileName) || !fileName.contains(".")) return "";
|
||||
return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
public static class Section {
|
||||
private final String sectionType;
|
||||
private final String sectionName;
|
||||
private final int sequence;
|
||||
private final String text;
|
||||
|
||||
public Section(String sectionType, String sectionName, int sequence, String text) {
|
||||
this.sectionType = sectionType;
|
||||
this.sectionName = sectionName;
|
||||
this.sequence = sequence;
|
||||
this.text = text;
|
||||
}
|
||||
public String getSectionType() { return sectionType; }
|
||||
public String getSectionName() { return sectionName; }
|
||||
public int getSequence() { return sequence; }
|
||||
public String getText() { return text; }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
package com.sztzjy.linkCommerce.training.knowledge;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/** Small deterministic keyword ranker used before a vector index is introduced. */
|
||||
public final class TrainingKnowledgeChunkRanker {
|
||||
private TrainingKnowledgeChunkRanker() {}
|
||||
|
||||
public static List<String> rank(List<String> texts, String query) {
|
||||
List<RankedText> items = new ArrayList<>();
|
||||
if (texts == null) return new ArrayList<>();
|
||||
Set<String> terms = terms(query);
|
||||
for (int index = 0; index < texts.size(); index++) {
|
||||
String text = StringUtils.defaultString(texts.get(index));
|
||||
items.add(new RankedText(text, index, score(text, terms)));
|
||||
}
|
||||
items.sort(Comparator.comparingInt(RankedText::getScore).reversed()
|
||||
.thenComparingInt(RankedText::getIndex));
|
||||
List<String> result = new ArrayList<>();
|
||||
for (RankedText item : items) result.add(item.getText());
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int score(String text, Set<String> terms) {
|
||||
if (terms.isEmpty()) return 0;
|
||||
String normalized = StringUtils.defaultString(text).toLowerCase(Locale.ROOT);
|
||||
int score = 0;
|
||||
for (String term : terms) {
|
||||
int from = 0;
|
||||
while ((from = normalized.indexOf(term, from)) >= 0) {
|
||||
score += term.length() * term.length();
|
||||
from += term.length();
|
||||
}
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
private static Set<String> terms(String query) {
|
||||
String normalized = StringUtils.defaultString(query).toLowerCase(Locale.ROOT).replaceAll("[^\\p{IsHan}a-z0-9]+", " ");
|
||||
Set<String> terms = new LinkedHashSet<>();
|
||||
for (String token : normalized.trim().split("\\s+")) {
|
||||
if (token.length() >= 2) terms.add(token);
|
||||
if (containsHan(token)) {
|
||||
for (int size = Math.min(4, token.length()); size >= 2; size--) {
|
||||
for (int start = 0; start + size <= token.length(); start++) terms.add(token.substring(start, start + size));
|
||||
}
|
||||
}
|
||||
}
|
||||
return terms;
|
||||
}
|
||||
|
||||
private static boolean containsHan(String value) {
|
||||
for (int i = 0; i < value.length(); i++) if (Character.UnicodeScript.of(value.charAt(i)) == Character.UnicodeScript.HAN) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static final class RankedText {
|
||||
private final String text; private final int index; private final int score;
|
||||
private RankedText(String text, int index, int score) { this.text = text; this.index = index; this.score = score; }
|
||||
private String getText() { return text; }
|
||||
private int getIndex() { return index; }
|
||||
private int getScore() { return score; }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
<?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="com.sztzjy.linkCommerce.mapper.TrainingKnowledgeChunkMapper">
|
||||
<resultMap id="BaseResultMap" type="com.sztzjy.linkCommerce.entity.TrainingKnowledgeChunk"><id column="id" property="id"/><result column="knowledge_file_id" property="knowledgeFileId"/><result column="task_key" property="taskKey"/><result column="effective_config_id" property="effectiveConfigId"/><result column="teaching_class_id" property="teachingClassId"/><result column="student_user_id" property="studentUserId"/><result column="step_no" property="stepNo"/><result column="section_type" property="sectionType"/><result column="section_name" property="sectionName"/><result column="section_sequence" property="sectionSequence"/><result column="content" property="content"/><result column="create_time" property="createTime"/></resultMap>
|
||||
<delete id="deleteByKnowledgeFileId">delete from training_knowledge_chunk where knowledge_file_id=#{knowledgeFileId}</delete>
|
||||
<insert id="insertBatch">insert into training_knowledge_chunk (id,knowledge_file_id,task_key,effective_config_id,teaching_class_id,student_user_id,step_no,section_type,section_name,section_sequence,content,create_time) values <foreach collection="items" item="item" separator=",">(#{item.id},#{item.knowledgeFileId},#{item.taskKey},#{item.effectiveConfigId},#{item.teachingClassId},#{item.studentUserId},#{item.stepNo},#{item.sectionType},#{item.sectionName},#{item.sectionSequence},#{item.content},#{item.createTime})</foreach></insert>
|
||||
<select id="selectForAi" resultMap="BaseResultMap">select c.* from training_knowledge_chunk c inner join training_knowledge_file f on f.id=c.knowledge_file_id where c.task_key=#{taskKey} and ((f.source_type='CASE' and c.effective_config_id=#{effectiveConfigId}) or (f.source_type='STUDENT_UPLOAD' and c.effective_config_id=#{effectiveConfigId} and c.teaching_class_id=#{teachingClassId} and c.student_user_id=#{studentUserId})) and f.parse_status='SUCCESS' order by f.update_time desc, c.section_sequence asc limit #{limit}</select>
|
||||
</mapper>
|
||||
@ -0,0 +1,16 @@
|
||||
<?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="com.sztzjy.linkCommerce.mapper.TrainingKnowledgeFileMapper">
|
||||
<resultMap id="BaseResultMap" type="com.sztzjy.linkCommerce.entity.TrainingKnowledgeFile">
|
||||
<id column="id" property="id"/><result column="source_type" property="sourceType"/><result column="config_scope" property="configScope"/>
|
||||
<result column="effective_config_id" property="effectiveConfigId"/><result column="task_id" property="taskId"/><result column="task_key" property="taskKey"/>
|
||||
<result column="teaching_class_id" property="teachingClassId"/><result column="student_user_id" property="studentUserId"/><result column="step_no" property="stepNo"/>
|
||||
<result column="file_name" property="fileName"/><result column="file_url" property="fileUrl"/><result column="content_hash" property="contentHash"/>
|
||||
<result column="parse_status" property="parseStatus"/><result column="parse_message" property="parseMessage"/><result column="section_count" property="sectionCount"/>
|
||||
<result column="create_time" property="createTime"/><result column="update_time" property="updateTime"/>
|
||||
</resultMap>
|
||||
<sql id="columns">id, source_type, config_scope, effective_config_id, task_id, task_key, teaching_class_id, student_user_id, step_no, file_name, file_url, content_hash, parse_status, parse_message, section_count, create_time, update_time</sql>
|
||||
<select id="selectBySourceScopeAndUrl" resultMap="BaseResultMap">select <include refid="columns"/> from training_knowledge_file where source_type=#{sourceType} and effective_config_id=#{effectiveConfigId} and file_url=#{fileUrl}<choose><when test="studentUserId != null and studentUserId != ''"> and student_user_id=#{studentUserId}</when><otherwise> and student_user_id is null</otherwise></choose> limit 1</select>
|
||||
<insert id="insert" parameterType="com.sztzjy.linkCommerce.entity.TrainingKnowledgeFile">insert into training_knowledge_file (<include refid="columns"/>) values (#{id},#{sourceType},#{configScope},#{effectiveConfigId},#{taskId},#{taskKey},#{teachingClassId},#{studentUserId},#{stepNo},#{fileName},#{fileUrl},#{contentHash},#{parseStatus},#{parseMessage},#{sectionCount},#{createTime},#{updateTime})</insert>
|
||||
<update id="update" parameterType="com.sztzjy.linkCommerce.entity.TrainingKnowledgeFile">update training_knowledge_file set file_name=#{fileName}, content_hash=#{contentHash}, parse_status=#{parseStatus}, parse_message=#{parseMessage}, section_count=#{sectionCount}, update_time=#{updateTime} where id=#{id}</update>
|
||||
</mapper>
|
||||
@ -0,0 +1,31 @@
|
||||
package com.sztzjy.linkCommerce.ai;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sztzjy.linkCommerce.config.ai.QwenProperties;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Base64;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class QwenVisionTextExtractorTest {
|
||||
|
||||
@Test
|
||||
void buildsCompatibleMultimodalRequestWithoutWritingTheImageToDisk() throws Exception {
|
||||
Path image = Files.createTempFile("knowledge-", ".png");
|
||||
Files.write(image, Base64.getDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADElEQVR42mNk+M/wHwAF/gL+Qanb9wAAAABJRU5ErkJggg=="));
|
||||
QwenProperties properties = new QwenProperties();
|
||||
properties.setVisionModel("qwen-vl-plus");
|
||||
QwenVisionTextExtractor extractor = new QwenVisionTextExtractor(properties, new ObjectMapper());
|
||||
|
||||
JsonNode request = new ObjectMapper().readTree(extractor.requestJson(image));
|
||||
|
||||
assertEquals("qwen-vl-plus", request.path("model").asText());
|
||||
assertEquals("user", request.path("messages").get(0).path("role").asText());
|
||||
assertTrue(request.path("messages").get(0).path("content").get(1).path("image_url").path("url").asText().startsWith("data:image/png;base64,"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package com.sztzjy.linkCommerce.training.knowledge;
|
||||
|
||||
import com.sztzjy.linkCommerce.entity.TrainingTask;
|
||||
import com.sztzjy.linkCommerce.entity.TrainingTaskClassConfig;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class EffectiveTrainingTaskContentTest {
|
||||
|
||||
@Test
|
||||
void usesTeacherValuesOnlyWhenTheyAreConfiguredAndKeepsAdminFallbacks() {
|
||||
TrainingTask base = new TrainingTask();
|
||||
base.setId("base-task");
|
||||
base.setBackground("管理员案例背景");
|
||||
base.setObjectives("管理员目标");
|
||||
base.setMaterialName("管理员案例.docx");
|
||||
base.setMaterialUrl("/file/admin-case.docx");
|
||||
|
||||
TrainingTaskClassConfig teacher = new TrainingTaskClassConfig();
|
||||
teacher.setId("class-task");
|
||||
teacher.setEnabled(true);
|
||||
teacher.setBackground("教师班级案例背景");
|
||||
teacher.setObjectives(" ");
|
||||
teacher.setMaterialUrl("/file/teacher-case.xlsx");
|
||||
|
||||
EffectiveTrainingTaskContent content = EffectiveTrainingTaskContent.resolve(base, teacher);
|
||||
|
||||
assertEquals("TEACHER", content.getScope());
|
||||
assertEquals("class-task", content.getEffectiveConfigId());
|
||||
assertEquals("教师班级案例背景", content.getBackground());
|
||||
assertEquals("管理员目标", content.getObjectives());
|
||||
assertEquals("管理员案例.docx", content.getMaterialName());
|
||||
assertEquals("/file/teacher-case.xlsx", content.getMaterialUrl());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
package com.sztzjy.linkCommerce.training.knowledge;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.poi.xwpf.usermodel.XWPFDocument;
|
||||
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class TrainingDocumentTextExtractorTest {
|
||||
|
||||
private final TrainingDocumentTextExtractor extractor = new TrainingDocumentTextExtractor();
|
||||
|
||||
@Test
|
||||
void extractsDocxParagraphs() throws Exception {
|
||||
Path document = Files.createTempFile("training-knowledge-", ".docx");
|
||||
try (XWPFDocument word = new XWPFDocument(); OutputStream output = Files.newOutputStream(document)) {
|
||||
XWPFParagraph paragraph = word.createParagraph();
|
||||
paragraph.createRun().setText("案例背景:智能健身镜面向居家锻炼用户");
|
||||
word.write(output);
|
||||
}
|
||||
|
||||
List<TrainingDocumentTextExtractor.Section> sections = extractor.extract(document, "案例资料.docx");
|
||||
|
||||
assertEquals(1, sections.size());
|
||||
assertEquals("document", sections.get(0).getSectionType());
|
||||
assertTrue(sections.get(0).getText().contains("智能健身镜"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractsEveryWorkbookSheetWithDisplayedValues() throws Exception {
|
||||
Path workbookFile = Files.createTempFile("training-knowledge-", ".xlsx");
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook(); OutputStream output = Files.newOutputStream(workbookFile)) {
|
||||
workbook.createSheet("市场数据").createRow(0).createCell(0).setCellValue("搜索次数");
|
||||
workbook.getSheetAt(0).createRow(1).createCell(0).setCellValue(2500);
|
||||
workbook.createSheet("权重").createRow(0).createCell(0).setCellValue("市场规模");
|
||||
workbook.write(output);
|
||||
}
|
||||
|
||||
List<TrainingDocumentTextExtractor.Section> sections = extractor.extract(workbookFile, "案例数据.xlsx");
|
||||
|
||||
assertEquals(2, sections.size());
|
||||
assertEquals("sheet", sections.get(0).getSectionType());
|
||||
assertEquals("市场数据", sections.get(0).getSectionName());
|
||||
assertTrue(sections.get(0).getText().contains("搜索次数"));
|
||||
assertTrue(sections.get(1).getText().contains("市场规模"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractsPdfTextByPage() throws Exception {
|
||||
Path pdf = Files.createTempFile("training-knowledge-", ".pdf");
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
PDPage page = new PDPage();
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream content = new PDPageContentStream(document, page)) {
|
||||
content.beginText();
|
||||
content.setFont(PDType1Font.HELVETICA, 12);
|
||||
content.newLineAtOffset(72, 720);
|
||||
content.showText("Market opportunity analysis");
|
||||
content.endText();
|
||||
}
|
||||
document.save(pdf.toFile());
|
||||
}
|
||||
|
||||
List<TrainingDocumentTextExtractor.Section> sections = extractor.extract(pdf, "case.pdf");
|
||||
|
||||
assertEquals(1, sections.size());
|
||||
assertEquals("page", sections.get(0).getSectionType());
|
||||
assertTrue(sections.get(0).getText().contains("Market opportunity"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
package com.sztzjy.linkCommerce.training.knowledge;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class TrainingKnowledgeChunkRankerTest {
|
||||
|
||||
@Test
|
||||
void putsChunksMatchingTheChineseQuestionAheadOfUnrelatedChunks() {
|
||||
List<String> ranked = TrainingKnowledgeChunkRanker.rank(Arrays.asList(
|
||||
"该 Sheet 记录了供应商成本和交期。",
|
||||
"产品在抖音渠道的用户画像、购买动机与评论分析。",
|
||||
"这是不相关的项目排期说明。"), "用户画像怎么分析?");
|
||||
|
||||
assertEquals("产品在抖音渠道的用户画像、购买动机与评论分析。", ranked.get(0));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue