feat: 增加实训 AI 知识库解析

main
陈沅 1 day ago
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";
}
}

@ -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;
}

@ -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());
}

@ -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; }
}

@ -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;
}
}

@ -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);
}

@ -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);
}

@ -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);
}

@ -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;
}
}
}

@ -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) {

@ -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;
}
}

@ -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

@ -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; }
}
}

@ -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:

@ -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,"));
}
}

@ -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<AiTrainingEvaluationView> 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

@ -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<String> 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));

@ -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…
Cancel
Save