diff --git a/docs/sql/2026-08-28-training-knowledge.sql b/docs/sql/2026-08-28-training-knowledge.sql new file mode 100644 index 0000000..15a085f --- /dev/null +++ b/docs/sql/2026-08-28-training-knowledge.sql @@ -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; diff --git a/src/main/java/com/sztzjy/linkCommerce/ai/QwenVisionTextExtractor.java b/src/main/java/com/sztzjy/linkCommerce/ai/QwenVisionTextExtractor.java new file mode 100644 index 0000000..04c878d --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/ai/QwenVisionTextExtractor.java @@ -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 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"; + } +} diff --git a/src/main/java/com/sztzjy/linkCommerce/config/ai/QwenProperties.java b/src/main/java/com/sztzjy/linkCommerce/config/ai/QwenProperties.java index e71d13a..c7d299f 100644 --- a/src/main/java/com/sztzjy/linkCommerce/config/ai/QwenProperties.java +++ b/src/main/java/com/sztzjy/linkCommerce/config/ai/QwenProperties.java @@ -10,6 +10,7 @@ public class QwenProperties { private String baseUrl; private String apiKey; private String model; + private String visionModel; private long timeoutMillis; public String getBaseUrl() { @@ -36,6 +37,14 @@ public class QwenProperties { this.model = model; } + public String getVisionModel() { + return visionModel; + } + + public void setVisionModel(String visionModel) { + this.visionModel = visionModel; + } + public long getTimeoutMillis() { return timeoutMillis; } diff --git a/src/main/java/com/sztzjy/linkCommerce/controller/stu/AiTrainingEvaluationController.java b/src/main/java/com/sztzjy/linkCommerce/controller/stu/AiTrainingEvaluationController.java index f7bbf51..2b88298 100644 --- a/src/main/java/com/sztzjy/linkCommerce/controller/stu/AiTrainingEvaluationController.java +++ b/src/main/java/com/sztzjy/linkCommerce/controller/stu/AiTrainingEvaluationController.java @@ -45,7 +45,8 @@ public class AiTrainingEvaluationController { HttpServletRequest request) { try { return new ResultEntity<>(HttpStatus.OK, "AI help generated", - aiTrainingEvaluationService.generateHelp(taskKey, currentUser(request), body == null ? null : body.getQuestion())); + aiTrainingEvaluationService.generateHelp(taskKey, currentUser(request), body == null ? null : body.getQuestion(), + body == null ? null : body.getStepContext())); } catch (ServiceException e) { return new ResultEntity<>(e.getCode(), e.getMessage()); } diff --git a/src/main/java/com/sztzjy/linkCommerce/entity/TrainingKnowledgeChunk.java b/src/main/java/com/sztzjy/linkCommerce/entity/TrainingKnowledgeChunk.java new file mode 100644 index 0000000..fa62742 --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/entity/TrainingKnowledgeChunk.java @@ -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; } +} diff --git a/src/main/java/com/sztzjy/linkCommerce/entity/TrainingKnowledgeFile.java b/src/main/java/com/sztzjy/linkCommerce/entity/TrainingKnowledgeFile.java new file mode 100644 index 0000000..8549623 --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/entity/TrainingKnowledgeFile.java @@ -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; } +} diff --git a/src/main/java/com/sztzjy/linkCommerce/entity/dto/AiTrainingHelpRequest.java b/src/main/java/com/sztzjy/linkCommerce/entity/dto/AiTrainingHelpRequest.java index b8ca7ab..b9ea2ee 100644 --- a/src/main/java/com/sztzjy/linkCommerce/entity/dto/AiTrainingHelpRequest.java +++ b/src/main/java/com/sztzjy/linkCommerce/entity/dto/AiTrainingHelpRequest.java @@ -2,6 +2,7 @@ package com.sztzjy.linkCommerce.entity.dto; public class AiTrainingHelpRequest { private String question; + private String stepContext; public AiTrainingHelpRequest() { } @@ -17,4 +18,12 @@ public class AiTrainingHelpRequest { public void setQuestion(String question) { this.question = question; } + + public String getStepContext() { + return stepContext; + } + + public void setStepContext(String stepContext) { + this.stepContext = stepContext; + } } diff --git a/src/main/java/com/sztzjy/linkCommerce/mapper/TrainingKnowledgeChunkMapper.java b/src/main/java/com/sztzjy/linkCommerce/mapper/TrainingKnowledgeChunkMapper.java new file mode 100644 index 0000000..2e477e1 --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/mapper/TrainingKnowledgeChunkMapper.java @@ -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 items); + List selectForAi(@Param("taskKey") String taskKey, + @Param("effectiveConfigId") String effectiveConfigId, + @Param("teachingClassId") String teachingClassId, + @Param("studentUserId") String studentUserId, + @Param("limit") int limit); +} diff --git a/src/main/java/com/sztzjy/linkCommerce/mapper/TrainingKnowledgeFileMapper.java b/src/main/java/com/sztzjy/linkCommerce/mapper/TrainingKnowledgeFileMapper.java new file mode 100644 index 0000000..a20afea --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/mapper/TrainingKnowledgeFileMapper.java @@ -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); +} diff --git a/src/main/java/com/sztzjy/linkCommerce/service/AiTrainingEvaluationService.java b/src/main/java/com/sztzjy/linkCommerce/service/AiTrainingEvaluationService.java index 115a778..c4fe546 100644 --- a/src/main/java/com/sztzjy/linkCommerce/service/AiTrainingEvaluationService.java +++ b/src/main/java/com/sztzjy/linkCommerce/service/AiTrainingEvaluationService.java @@ -8,5 +8,9 @@ public interface AiTrainingEvaluationService { AiTrainingEvaluationView generateHelp(String taskKey, JwtUser user, String question); + default AiTrainingEvaluationView generateHelp(String taskKey, JwtUser user, String question, String stepContext) { + return generateHelp(taskKey, user, question); + } + AiTrainingEvaluationView generateAssessment(String taskKey, JwtUser user); } diff --git a/src/main/java/com/sztzjy/linkCommerce/service/TrainingKnowledgeService.java b/src/main/java/com/sztzjy/linkCommerce/service/TrainingKnowledgeService.java new file mode 100644 index 0000000..7769a7c --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/service/TrainingKnowledgeService.java @@ -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); +} diff --git a/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java b/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java index 290f4e4..d08890a 100644 --- a/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java +++ b/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java @@ -11,12 +11,16 @@ import com.sztzjy.linkCommerce.entity.AiTrainingEvaluation; import com.sztzjy.linkCommerce.entity.StudentTrainingAnswer; import com.sztzjy.linkCommerce.entity.TeachingClassStudent; import com.sztzjy.linkCommerce.entity.TrainingTask; +import com.sztzjy.linkCommerce.entity.TrainingTaskClassConfig; import com.sztzjy.linkCommerce.entity.dto.AiTrainingEvaluationView; import com.sztzjy.linkCommerce.mapper.AiTrainingEvaluationMapper; import com.sztzjy.linkCommerce.mapper.StudentTrainingAnswerMapper; import com.sztzjy.linkCommerce.mapper.TeachingClassStudentMapper; import com.sztzjy.linkCommerce.mapper.TrainingTaskMapper; +import com.sztzjy.linkCommerce.mapper.TrainingTaskClassConfigMapper; import com.sztzjy.linkCommerce.service.AiTrainingEvaluationService; +import com.sztzjy.linkCommerce.service.TrainingKnowledgeService; +import com.sztzjy.linkCommerce.training.knowledge.EffectiveTrainingTaskContent; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; @@ -34,6 +38,7 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ private static final String SUCCEEDED = "SUCCEEDED"; private static final int MAX_ERROR_LENGTH = 1000; private static final int MAX_HELP_QUESTION_LENGTH = 500; + private static final int MAX_STEP_CONTEXT_LENGTH = 2000; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -45,6 +50,10 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ public StudentTrainingAnswerMapper studentTrainingAnswerMapper; @Autowired public TeachingClassStudentMapper teachingClassStudentMapper; + @Autowired(required = false) + public TrainingTaskClassConfigMapper trainingTaskClassConfigMapper; + @Autowired(required = false) + public TrainingKnowledgeService trainingKnowledgeService; @Autowired public QwenChatClient qwenChatClient; @Autowired @@ -60,7 +69,13 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ @Override public AiTrainingEvaluationView generateHelp(String taskKey, JwtUser user, String question) { + return generateHelp(taskKey, user, question, ""); + } + + @Override + public AiTrainingEvaluationView generateHelp(String taskKey, JwtUser user, String question, String stepContext) { String normalizedQuestion = normalizeHelpQuestion(question); + String normalizedStepContext = normalizeStepContext(stepContext); EvaluationContext context = resolveContext(taskKey, user, true); AiTrainingEvaluation evaluation = getOrCreate(context, user); if (PROCESSING.equals(evaluation.getHelpStatus())) { @@ -70,10 +85,11 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ return currentView(context, user); } - String taskSnapshot = taskSnapshot(context.task); - String answerSnapshot = helpAnswerSnapshot(normalizedQuestion, context.answer); + String taskSnapshot = taskSnapshot(context.effectiveContent); + String answerSnapshot = helpAnswerSnapshot(normalizedQuestion, normalizedStepContext, context.answer); + String knowledgeContext = knowledgeContext(context, user, normalizedQuestion + " " + normalizedStepContext); try { - String rawResponse = qwenChatClient.completeJson(helpSystemPrompt(), helpUserPrompt(taskSnapshot, answerSnapshot, normalizedQuestion)); + String rawResponse = qwenChatClient.completeJson(helpSystemPrompt(), helpUserPrompt(taskSnapshot, answerSnapshot, normalizedQuestion, normalizedStepContext, knowledgeContext)); validateHelp(rawResponse); Date completedAt = new Date(); AiTrainingEvaluation completed = new AiTrainingEvaluation(); @@ -106,10 +122,11 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ return currentView(context, user); } - String taskSnapshot = taskSnapshot(context.task); + String taskSnapshot = taskSnapshot(context.effectiveContent); String answerSnapshot = answerSnapshot(context.answer); + String knowledgeContext = knowledgeContext(context, user, ""); try { - String rawResponse = qwenChatClient.completeJson(assessmentSystemPrompt(), assessmentUserPrompt(taskSnapshot, answerSnapshot)); + String rawResponse = qwenChatClient.completeJson(assessmentSystemPrompt(), assessmentUserPrompt(taskSnapshot, answerSnapshot, knowledgeContext)); int score = validateAssessment(rawResponse); Date completedAt = new Date(); AiTrainingEvaluation completed = new AiTrainingEvaluation(); @@ -145,7 +162,10 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ if (requireAnswer && (answer == null || !hasAnyAnswer(answer))) { throw new ServiceException(HttpStatus.BAD_REQUEST, "Please complete at least one training step before requesting AI evaluation"); } - return new EvaluationContext(task, member.getTeachingClassId(), answer); + TrainingTaskClassConfig classConfig = trainingTaskClassConfigMapper == null ? null + : trainingTaskClassConfigMapper.selectByTeachingClassAndTaskKey(member.getTeachingClassId(), task.getTaskKey()); + return new EvaluationContext(task, member.getTeachingClassId(), answer, + EffectiveTrainingTaskContent.resolve(task, classConfig)); } private void completeAssessmentAndWriteScore(AiTrainingEvaluation completed, @@ -243,14 +263,32 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ return normalized; } - private String taskSnapshot(TrainingTask task) { + private String normalizeStepContext(String stepContext) { + return StringUtils.abbreviate(StringUtils.trimToEmpty(stepContext), MAX_STEP_CONTEXT_LENGTH); + } + + private String taskSnapshot(EffectiveTrainingTaskContent content) { ObjectNode node = objectMapper.createObjectNode(); - node.put("background", task.getBackground()); - node.put("objectives", task.getObjectives()); - node.put("requirements", task.getRequirements()); + node.put("configScope", content.getScope()); + node.put("knowledge", content.getKnowledge()); + node.put("background", content.getBackground()); + node.put("objectives", content.getObjectives()); + node.put("requirements", content.getRequirements()); + node.put("steps", content.getSteps()); + node.put("materialName", content.getMaterialName()); return json(node); } + private String knowledgeContext(EvaluationContext context, JwtUser user, String query) { + if (trainingKnowledgeService == null) return ""; + try { + return trainingKnowledgeService.prepareContext(context.task, context.effectiveContent, context.teachingClassId, + user.getUserId(), context.answer, query); + } catch (RuntimeException ignored) { + return ""; + } + } + private String answerSnapshot(StudentTrainingAnswer answer) { ObjectNode node = objectMapper.createObjectNode(); node.put("step1Answer", answer.getStep1Answer()); @@ -261,9 +299,10 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ return json(node); } - private String helpAnswerSnapshot(String question, StudentTrainingAnswer answer) { + private String helpAnswerSnapshot(String question, String stepContext, StudentTrainingAnswer answer) { ObjectNode node = objectMapper.createObjectNode(); node.put("studentQuestion", question); + node.put("currentStepContext", stepContext); node.put("studentAnswers", answerSnapshot(answer)); return json(node); } @@ -351,16 +390,18 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ return "You are a training learning assistant. Return only JSON with overallDiagnosis, strengths, improvementAreas, and recommendedActions. Do not include a score."; } - private String helpUserPrompt(String taskSnapshot, String answerSnapshot, String question) { - return "Student question: " + question + "\nTask snapshot: " + taskSnapshot + "\nStudent answer snapshot: " + answerSnapshot; + private String helpUserPrompt(String taskSnapshot, String answerSnapshot, String question, String stepContext, String knowledgeContext) { + return "Student question: " + question + "\nCurrent page/step instruction: " + stepContext + "\nTask snapshot: " + taskSnapshot + "\nStudent answer snapshot: " + answerSnapshot + + "\nResolved case material and this student's uploaded-file excerpts: " + knowledgeContext; } private String assessmentSystemPrompt() { return "You are a training assessor. Return only JSON with score, overallComment, criteria, strengths, and improvements. Create 3 to 5 criteria; all maxScore values total 100, all criterion score values total 100, and the integer score equals that total."; } - private String assessmentUserPrompt(String taskSnapshot, String answerSnapshot) { - return "Task snapshot: " + taskSnapshot + "\nStudent answer snapshot: " + answerSnapshot; + private String assessmentUserPrompt(String taskSnapshot, String answerSnapshot, String knowledgeContext) { + return "Task snapshot: " + taskSnapshot + "\nStudent answer snapshot: " + answerSnapshot + + "\nResolved case material and this student's uploaded-file excerpts: " + knowledgeContext; } private AiTrainingEvaluationView toView(AiTrainingEvaluation evaluation) { @@ -393,11 +434,14 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ private final TrainingTask task; private final String teachingClassId; private final StudentTrainingAnswer answer; + private final EffectiveTrainingTaskContent effectiveContent; - private EvaluationContext(TrainingTask task, String teachingClassId, StudentTrainingAnswer answer) { + private EvaluationContext(TrainingTask task, String teachingClassId, StudentTrainingAnswer answer, + EffectiveTrainingTaskContent effectiveContent) { this.task = task; this.teachingClassId = teachingClassId; this.answer = answer; + this.effectiveContent = effectiveContent; } } } diff --git a/src/main/java/com/sztzjy/linkCommerce/service/impl/StudentTrainingAnswerServiceImpl.java b/src/main/java/com/sztzjy/linkCommerce/service/impl/StudentTrainingAnswerServiceImpl.java index 29fe211..fe622e6 100644 --- a/src/main/java/com/sztzjy/linkCommerce/service/impl/StudentTrainingAnswerServiceImpl.java +++ b/src/main/java/com/sztzjy/linkCommerce/service/impl/StudentTrainingAnswerServiceImpl.java @@ -7,10 +7,14 @@ import com.sztzjy.linkCommerce.config.security.JwtUser; import com.sztzjy.linkCommerce.entity.StudentTrainingAnswer; import com.sztzjy.linkCommerce.entity.TeachingClassStudent; import com.sztzjy.linkCommerce.entity.TrainingTask; +import com.sztzjy.linkCommerce.entity.TrainingTaskClassConfig; import com.sztzjy.linkCommerce.mapper.StudentTrainingAnswerMapper; import com.sztzjy.linkCommerce.mapper.TeachingClassStudentMapper; +import com.sztzjy.linkCommerce.mapper.TrainingTaskClassConfigMapper; import com.sztzjy.linkCommerce.mapper.TrainingTaskMapper; import com.sztzjy.linkCommerce.service.StudentTrainingAnswerService; +import com.sztzjy.linkCommerce.service.TrainingKnowledgeService; +import com.sztzjy.linkCommerce.training.knowledge.EffectiveTrainingTaskContent; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; @@ -40,6 +44,12 @@ public class StudentTrainingAnswerServiceImpl implements StudentTrainingAnswerSe @Autowired public TeachingClassStudentMapper teachingClassStudentMapper; + @Autowired(required = false) + public TrainingTaskClassConfigMapper trainingTaskClassConfigMapper; + + @Autowired(required = false) + public TrainingKnowledgeService trainingKnowledgeService; + @Autowired(required = false) public JdbcTemplate jdbcTemplate; @@ -91,9 +101,23 @@ public class StudentTrainingAnswerServiceImpl implements StudentTrainingAnswerSe StudentTrainingAnswer saved = studentTrainingAnswerMapper.selectByStudentClassAndTask( user.getUserId(), resolvedTeachingClassId, task.getId()); syncDetailTables(saved); + indexKnowledge(task, resolvedTeachingClassId, user.getUserId(), saved); return saved; } + private void indexKnowledge(TrainingTask task, String teachingClassId, String studentUserId, + StudentTrainingAnswer answer) { + if (trainingKnowledgeService == null || answer == null) return; + try { + TrainingTaskClassConfig classConfig = trainingTaskClassConfigMapper == null ? null + : trainingTaskClassConfigMapper.selectByTeachingClassAndTaskKey(teachingClassId, task.getTaskKey()); + trainingKnowledgeService.indexStudentAnswer(task, + EffectiveTrainingTaskContent.resolve(task, classConfig), teachingClassId, studentUserId, answer); + } catch (Exception ignored) { + // Attachment parsing is supplementary: a malformed file must not make a student answer fail to save. + } + } + @Override @Transactional(rollbackFor = Exception.class) public void delete(String taskKey, String teachingClassId, JwtUser user) { diff --git a/src/main/java/com/sztzjy/linkCommerce/service/impl/TrainingKnowledgeServiceImpl.java b/src/main/java/com/sztzjy/linkCommerce/service/impl/TrainingKnowledgeServiceImpl.java new file mode 100644 index 0000000..2bd063a --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/service/impl/TrainingKnowledgeServiceImpl.java @@ -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 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 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 item : urls.entrySet()) { + index(task, content, STUDENT_UPLOAD, "STUDENT", teachingClassId, studentUserId, + item.getValue(), fileName(item.getKey()), item.getKey()); + } + } + + private void collectUrls(Map 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 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 chunks(TrainingKnowledgeFile file, EffectiveTrainingTaskContent content, + List sections, Date now) { + List 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 chunks, String query) { + if (chunks == null || chunks.isEmpty()) return ""; + List sourceTexts = new ArrayList<>(); + Map> 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 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; + } +} diff --git a/src/main/java/com/sztzjy/linkCommerce/service/impl/TrainingTaskServiceImpl.java b/src/main/java/com/sztzjy/linkCommerce/service/impl/TrainingTaskServiceImpl.java index 391feab..df678f1 100644 --- a/src/main/java/com/sztzjy/linkCommerce/service/impl/TrainingTaskServiceImpl.java +++ b/src/main/java/com/sztzjy/linkCommerce/service/impl/TrainingTaskServiceImpl.java @@ -19,6 +19,8 @@ import com.sztzjy.linkCommerce.mapper.TrainingTaskClassConfigMapper; import com.sztzjy.linkCommerce.mapper.TrainingTaskMapper; import com.sztzjy.linkCommerce.service.TrainingTaskService; import com.sztzjy.linkCommerce.service.StudentTeachingClassResolver; +import com.sztzjy.linkCommerce.service.TrainingKnowledgeService; +import com.sztzjy.linkCommerce.training.knowledge.EffectiveTrainingTaskContent; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; @@ -66,6 +68,9 @@ public class TrainingTaskServiceImpl implements TrainingTaskService { @Autowired(required = false) public JdbcTemplate jdbcTemplate; + @Autowired(required = false) + public TrainingKnowledgeService trainingKnowledgeService; + private boolean trainingTaskTableChecked = false; private boolean trainingTaskClassConfigTableChecked = false; private volatile boolean defaultTasksInitialized = false; @@ -138,7 +143,9 @@ public class TrainingTaskServiceImpl implements TrainingTaskService { task.setUpdateTime(new Date()); trainingTaskMapper.updateByPrimaryKeySelective(task); synchronizeClassTaskDefaults(existing, task); - return trainingTaskMapper.selectByPrimaryKey(id); + TrainingTask saved = trainingTaskMapper.selectByPrimaryKey(id); + indexCaseMaterial(saved, EffectiveTrainingTaskContent.resolve(saved, null), null); + return saved; } @Override @@ -277,12 +284,24 @@ public class TrainingTaskServiceImpl implements TrainingTaskService { config.setId(UUID.randomUUID().toString()); config.setCreateTime(new Date()); trainingTaskClassConfigMapper.insertSelective(config); + indexCaseMaterial(baseTask, EffectiveTrainingTaskContent.resolve(baseTask, config), teachingClassId); return toTask(config); } config.setId(existing.getId()); trainingTaskClassConfigMapper.updateByPrimaryKeySelective(config); TrainingTaskClassConfig saved = trainingTaskClassConfigMapper.selectByPrimaryKey(existing.getId()); - return toTask(saved == null ? config : saved); + TrainingTaskClassConfig effective = saved == null ? config : saved; + indexCaseMaterial(baseTask, EffectiveTrainingTaskContent.resolve(baseTask, effective), teachingClassId); + return toTask(effective); + } + + private void indexCaseMaterial(TrainingTask task, EffectiveTrainingTaskContent content, String teachingClassId) { + if (trainingKnowledgeService == null || task == null) return; + try { + trainingKnowledgeService.indexCaseMaterial(task, content, teachingClassId); + } catch (Exception ignored) { + // Case-file parsing must not prevent an administrator or teacher from saving the task configuration. + } } @Override diff --git a/src/main/java/com/sztzjy/linkCommerce/training/knowledge/EffectiveTrainingTaskContent.java b/src/main/java/com/sztzjy/linkCommerce/training/knowledge/EffectiveTrainingTaskContent.java new file mode 100644 index 0000000..24fd0ae --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/training/knowledge/EffectiveTrainingTaskContent.java @@ -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; } +} diff --git a/src/main/java/com/sztzjy/linkCommerce/training/knowledge/TrainingDocumentTextExtractor.java b/src/main/java/com/sztzjy/linkCommerce/training/knowledge/TrainingDocumentTextExtractor.java new file mode 100644 index 0000000..3c02625 --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/training/knowledge/TrainingDocumentTextExtractor.java @@ -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
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
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
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
workbook(Path file) throws Exception { + List
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 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
slidesPptx(Path file) throws Exception { + List
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
slidesPpt(Path file) throws Exception { + List
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
pdf(Path file) throws Exception { + List
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
plainText(Path file) throws Exception { + return sections("document", "正文", new String(Files.readAllBytes(file), StandardCharsets.UTF_8)); + } + + private List
sections(String type, String name, String rawText) { + String text = StringUtils.normalizeSpace(StringUtils.defaultString(rawText)); + if (StringUtils.isBlank(text)) return Collections.emptyList(); + List
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; } + } +} diff --git a/src/main/java/com/sztzjy/linkCommerce/training/knowledge/TrainingKnowledgeChunkRanker.java b/src/main/java/com/sztzjy/linkCommerce/training/knowledge/TrainingKnowledgeChunkRanker.java new file mode 100644 index 0000000..75c9118 --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/training/knowledge/TrainingKnowledgeChunkRanker.java @@ -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 rank(List texts, String query) { + List items = new ArrayList<>(); + if (texts == null) return new ArrayList<>(); + Set 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 result = new ArrayList<>(); + for (RankedText item : items) result.add(item.getText()); + return result; + } + + private static int score(String text, Set 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 terms(String query) { + String normalized = StringUtils.defaultString(query).toLowerCase(Locale.ROOT).replaceAll("[^\\p{IsHan}a-z0-9]+", " "); + Set 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; } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 44c17cb..9fff1cd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -107,6 +107,7 @@ ai: base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 api-key: sk-d34a4eff2cd74fdfab52e732cfd0f7aa model: qwen-plus + vision-model: qwen-vl-plus timeout-millis: 60000 ssl: diff --git a/src/main/resources/mappers/TrainingKnowledgeChunkMapper.xml b/src/main/resources/mappers/TrainingKnowledgeChunkMapper.xml new file mode 100644 index 0000000..7df6231 --- /dev/null +++ b/src/main/resources/mappers/TrainingKnowledgeChunkMapper.xml @@ -0,0 +1,8 @@ + + + + + delete from training_knowledge_chunk where knowledge_file_id=#{knowledgeFileId} + 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 (#{item.id},#{item.knowledgeFileId},#{item.taskKey},#{item.effectiveConfigId},#{item.teachingClassId},#{item.studentUserId},#{item.stepNo},#{item.sectionType},#{item.sectionName},#{item.sectionSequence},#{item.content},#{item.createTime}) + + diff --git a/src/main/resources/mappers/TrainingKnowledgeFileMapper.xml b/src/main/resources/mappers/TrainingKnowledgeFileMapper.xml new file mode 100644 index 0000000..18753a6 --- /dev/null +++ b/src/main/resources/mappers/TrainingKnowledgeFileMapper.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + 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 + + insert into training_knowledge_file () values (#{id},#{sourceType},#{configScope},#{effectiveConfigId},#{taskId},#{taskKey},#{teachingClassId},#{studentUserId},#{stepNo},#{fileName},#{fileUrl},#{contentHash},#{parseStatus},#{parseMessage},#{sectionCount},#{createTime},#{updateTime}) + 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} + diff --git a/src/test/java/com/sztzjy/linkCommerce/ai/QwenVisionTextExtractorTest.java b/src/test/java/com/sztzjy/linkCommerce/ai/QwenVisionTextExtractorTest.java new file mode 100644 index 0000000..63f03a0 --- /dev/null +++ b/src/test/java/com/sztzjy/linkCommerce/ai/QwenVisionTextExtractorTest.java @@ -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,")); + } +} diff --git a/src/test/java/com/sztzjy/linkCommerce/controller/stu/AiTrainingEvaluationControllerTest.java b/src/test/java/com/sztzjy/linkCommerce/controller/stu/AiTrainingEvaluationControllerTest.java index c7e8335..ef3c5a0 100644 --- a/src/test/java/com/sztzjy/linkCommerce/controller/stu/AiTrainingEvaluationControllerTest.java +++ b/src/test/java/com/sztzjy/linkCommerce/controller/stu/AiTrainingEvaluationControllerTest.java @@ -24,13 +24,13 @@ class AiTrainingEvaluationControllerTest { controller.aiTrainingEvaluationService = mock(AiTrainingEvaluationService.class); AiTrainingEvaluationView view = new AiTrainingEvaluationView(); view.setHelpStatus("SUCCEEDED"); - when(controller.aiTrainingEvaluationService.generateHelp("task-key", controller.user, "如何区分核心层和有形层?")).thenReturn(view); + when(controller.aiTrainingEvaluationService.generateHelp("task-key", controller.user, "如何区分核心层和有形层?", null)).thenReturn(view); ResultEntity result = controller.help("task-key", new AiTrainingHelpRequest("如何区分核心层和有形层?"), mock(HttpServletRequest.class)); assertEquals(HttpStatus.OK, result.getStatusCode()); assertEquals("SUCCEEDED", result.getBody().getData().getHelpStatus()); - verify(controller.aiTrainingEvaluationService).generateHelp("task-key", controller.user, "如何区分核心层和有形层?"); + verify(controller.aiTrainingEvaluationService).generateHelp("task-key", controller.user, "如何区分核心层和有形层?", null); } @Test diff --git a/src/test/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImplTest.java b/src/test/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImplTest.java index 1f578ca..3c2f7e3 100644 --- a/src/test/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImplTest.java +++ b/src/test/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImplTest.java @@ -94,6 +94,22 @@ class AiTrainingEvaluationServiceImplTest { assertTrue(userPrompt.getValue().contains("Student question: 如何区分核心层和有形层?")); } + @Test + void helpPromptIncludesCurrentStepContext() { + AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null)); + AiTrainingEvaluation evaluation = evaluation("NOT_STARTED", "NOT_STARTED"); + when(service.aiTrainingEvaluationMapper.findByStudentClassAndTask("stu-1", "class-1", "task-1")) + .thenReturn(null, evaluation); + when(service.aiTrainingEvaluationMapper.claimHelp(eq("evaluation-1"), any(Date.class))).thenReturn(1); + when(service.qwenChatClient.completeJson(any(), any())).thenReturn(helpJson()); + + service.generateHelp("new-product-survey", student(), "这一页怎么做?", "当前步骤:产品三层分析"); + + ArgumentCaptor userPrompt = ArgumentCaptor.forClass(String.class); + verify(service.qwenChatClient).completeJson(any(), userPrompt.capture()); + assertTrue(userPrompt.getValue().contains("Current page/step instruction: 当前步骤:产品三层分析")); + } + @Test void assessmentSuccessWritesScoreToAnswer() { AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null)); diff --git a/src/test/java/com/sztzjy/linkCommerce/training/knowledge/EffectiveTrainingTaskContentTest.java b/src/test/java/com/sztzjy/linkCommerce/training/knowledge/EffectiveTrainingTaskContentTest.java new file mode 100644 index 0000000..61c72cf --- /dev/null +++ b/src/test/java/com/sztzjy/linkCommerce/training/knowledge/EffectiveTrainingTaskContentTest.java @@ -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()); + } +} diff --git a/src/test/java/com/sztzjy/linkCommerce/training/knowledge/TrainingDocumentTextExtractorTest.java b/src/test/java/com/sztzjy/linkCommerce/training/knowledge/TrainingDocumentTextExtractorTest.java new file mode 100644 index 0000000..516dc1e --- /dev/null +++ b/src/test/java/com/sztzjy/linkCommerce/training/knowledge/TrainingDocumentTextExtractorTest.java @@ -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 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 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 sections = extractor.extract(pdf, "case.pdf"); + + assertEquals(1, sections.size()); + assertEquals("page", sections.get(0).getSectionType()); + assertTrue(sections.get(0).getText().contains("Market opportunity")); + } +} diff --git a/src/test/java/com/sztzjy/linkCommerce/training/knowledge/TrainingKnowledgeChunkRankerTest.java b/src/test/java/com/sztzjy/linkCommerce/training/knowledge/TrainingKnowledgeChunkRankerTest.java new file mode 100644 index 0000000..0cb5ced --- /dev/null +++ b/src/test/java/com/sztzjy/linkCommerce/training/knowledge/TrainingKnowledgeChunkRankerTest.java @@ -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 ranked = TrainingKnowledgeChunkRanker.rank(Arrays.asList( + "该 Sheet 记录了供应商成本和交期。", + "产品在抖音渠道的用户画像、购买动机与评论分析。", + "这是不相关的项目排期说明。"), "用户画像怎么分析?"); + + assertEquals("产品在抖音渠道的用户画像、购买动机与评论分析。", ranked.get(0)); + } +}