feat(knowledge): add short-term ask memory

This commit is contained in:
2026-07-18 21:22:49 +08:00
parent 2eda757483
commit 064816a784
33 changed files with 1348 additions and 99 deletions
@@ -1,12 +1,16 @@
package org.dromara.aihr.knowledge.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse;
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService;
import org.dromara.common.core.domain.R;
import org.dromara.system.service.ISysOssService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -15,6 +19,7 @@ import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
@RestController
@@ -23,6 +28,7 @@ import java.util.List;
public class AihrKnowledgeQueryController {
private final AihrKnowledgeQueryService queryService;
private final ISysOssService ossService;
@SaCheckLogin
@PostMapping("/query")
@@ -34,13 +40,27 @@ public class AihrKnowledgeQueryController {
@PostMapping(value = "/query-media", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public R<QueryResponse> queryMedia(@RequestPart("file") MultipartFile file,
@RequestParam(value = "queryText", required = false) String queryText,
@RequestParam(value = "position", required = false) String position) {
@RequestParam(value = "position", required = false) String position,
@RequestParam(value = "conversationId", required = false) String conversationId,
@RequestParam(value = "contextVersion", required = false) Long contextVersion) {
String question = queryText == null || queryText.isBlank()
? "请根据附件现场情况,告诉我应该按什么 SOP 处理"
: queryText.trim();
QueryRequest request = new QueryRequest(
question, List.of(), "sop", position, "mobile_uni_media", 5, null
question, List.of(), "sop", position, "mobile_uni_media", 5, null,
conversationId, contextVersion
);
return R.ok(queryService.queryInternalMedia(request, file));
}
@SaCheckLogin
@GetMapping("/resources/{attachmentId}/content")
public void resource(@PathVariable Long attachmentId, HttpServletResponse response) throws IOException {
Long ossId = queryService.authorizedResourceOssId(attachmentId);
if (ossId == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "资料不存在或无权访问");
return;
}
ossService.download(ossId, response);
}
}
@@ -16,8 +16,14 @@ public final class AihrKnowledgeQueryDto {
String position,
String source,
Integer limit,
String toolCode
String toolCode,
String conversationId,
Long contextVersion
) {
public QueryRequest(String queryText, List<String> spaceCodes, String category, String position,
String source, Integer limit, String toolCode) {
this(queryText, spaceCodes, category, position, source, limit, toolCode, null, null);
}
}
public record Citation(
@@ -30,6 +36,14 @@ public final class AihrKnowledgeQueryDto {
) {
}
public record Resource(
Long attachmentId,
String title,
String type,
String contentUrl
) {
}
public record QueryResponse(
String requestId,
String queryText,
@@ -39,7 +53,18 @@ public final class AihrKnowledgeQueryDto {
boolean noEvidence,
String promptVersion,
SearchResponse legacy,
Object data
Object data,
String conversationId,
Long contextVersion,
String intent,
String rewrittenQuery,
List<Resource> resources
) {
public QueryResponse(String requestId, String queryText, String answer, List<Citation> citations,
List<String> usedSpaceCodes, boolean noEvidence, String promptVersion,
SearchResponse legacy, Object data) {
this(requestId, queryText, answer, citations, usedSpaceCodes, noEvidence, promptVersion,
legacy, data, null, null, "QA", queryText, List.of());
}
}
}
@@ -0,0 +1,371 @@
package org.dromara.aihr.knowledge.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Citation;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
import org.dromara.aihr.service.AihrModelSeedService;
import org.dromara.aihr.service.AihrSensitiveText;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Pattern;
@Service
@RequiredArgsConstructor
@Slf4j
public class AihrKnowledgeConversationService {
private static final int MAX_TURNS = 6;
private static final int MAX_QUESTION = 600;
private static final int MAX_ANSWER = 800;
private static final int MAX_MODEL_CONTEXT = 6000;
private static final long CLEANUP_INTERVAL_MILLIS = 5 * 60 * 1000L;
private static final Pattern CONVERSATION_ID = Pattern.compile("[A-Za-z0-9_-]{8,64}");
private static final Set<String> INTENTS = Set.of("QA", "FILE", "VIDEO", "DATA_TOOL");
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper;
private final AihrModelSeedService modelService;
private final AtomicLong nextCleanupAt = new AtomicLong();
public ConversationContext prepare(AihrKnowledgePrincipal principal, AuthenticatedApp app, QueryRequest request) {
String conversationId = clean(request.conversationId());
if (conversationId.isBlank()) {
return stateless(request.queryText(), request.toolCode());
}
if (!CONVERSATION_ID.matcher(conversationId).matches()) {
throw new ServiceException("conversationId 格式无效", HttpStatus.BAD_REQUEST);
}
ConversationRow row;
try {
row = findRow(principal, app, conversationId);
purgeExpiredLazily(principal, app, conversationId);
} catch (DataAccessException ex) {
log.warn("knowledge conversation unavailable, using stateless query(处理错误已隐藏)");
RewritePlan plan = fallbackPlan(List.of(), request.queryText(), request.toolCode());
return new ConversationContext(conversationId, false, false, 0L, List.of(),
plan.rewrittenQuery(), plan.intent(), plan.toolCode());
}
LocalDateTime now = LocalDateTime.now();
boolean expired = row != null && (row.expiresTime() == null || !row.expiresTime().isAfter(now));
long storedVersion = row == null ? 0L : row.version();
Long expectedVersion = request.contextVersion();
if (!expired && row != null && (expectedVersion == null || expectedVersion != storedVersion)) {
throw conflict();
}
List<Turn> turns = row == null || expired ? List.of() : readTurns(row.contextJson());
RewritePlan plan = rewrite(turns, request.queryText(), request.toolCode());
return new ConversationContext(conversationId, true, row != null, storedVersion, turns,
plan.rewrittenQuery(), plan.intent(), plan.toolCode());
}
public static ConversationContext stateless(String queryText, String explicitToolCode) {
RewritePlan plan = fallbackPlan(List.of(), queryText, explicitToolCode);
return new ConversationContext(null, false, false, 0L, List.of(),
plan.rewrittenQuery(), plan.intent(), plan.toolCode());
}
public Long append(AihrKnowledgePrincipal principal, AuthenticatedApp app, ConversationContext context,
String question, String answer, List<Citation> citations) {
if (!context.stateful()) {
return null;
}
List<Turn> turns = new ArrayList<>(context.turns());
List<String> docIds = citations == null ? List.of() : citations.stream()
.map(Citation::docId).filter(AihrKnowledgeConversationService::hasText).distinct().limit(5).toList();
List<String> titles = citations == null ? List.of() : citations.stream()
.map(Citation::title).filter(AihrKnowledgeConversationService::hasText)
.map(title -> sanitize(title, 200)).distinct().limit(5).toList();
turns.add(new Turn(
sanitize(question, MAX_QUESTION),
sanitize(context.rewrittenQuery(), MAX_QUESTION),
context.intent(),
sanitize(answer, MAX_ANSWER),
docIds,
titles
));
if (turns.size() > MAX_TURNS) {
turns = new ArrayList<>(turns.subList(turns.size() - MAX_TURNS, turns.size()));
}
long nextVersion = context.storedVersion() + 1L;
try {
String json = objectMapper.writeValueAsString(turns);
int updated;
if (context.existingRow()) {
updated = jdbcTemplate.update("""
update aihr_knowledge_conversation
set version = ?, context_json = ?, expires_time = date_add(now(), interval 30 minute),
update_time = now()
where tenant_id = ? and app_id = ? and user_id = ? and conversation_id = ? and version = ?
""", nextVersion, json, principal.tenantId(), app.id(), principal.userId(),
context.conversationId(), context.storedVersion());
} else {
updated = jdbcTemplate.update("""
insert into aihr_knowledge_conversation
(tenant_id, app_id, user_id, conversation_id, version, context_json,
expires_time, create_time, update_time)
values (?, ?, ?, ?, ?, ?, date_add(now(), interval 30 minute), now(), now())
""", principal.tenantId(), app.id(), principal.userId(), context.conversationId(),
nextVersion, json);
}
if (updated != 1) {
throw conflict();
}
return nextVersion;
} catch (DataIntegrityViolationException ex) {
throw conflict();
} catch (ServiceException ex) {
throw ex;
} catch (Exception ex) {
log.warn("knowledge conversation persistence failed, answer remains stateless(处理错误已隐藏)");
return null;
}
}
private ConversationRow findRow(AihrKnowledgePrincipal principal, AuthenticatedApp app, String conversationId) {
List<ConversationRow> rows = jdbcTemplate.query("""
select version, context_json, expires_time
from aihr_knowledge_conversation
where tenant_id = ? and app_id = ? and user_id = ? and conversation_id = ?
limit 1
""", (rs, rowNum) -> new ConversationRow(
rs.getLong("version"),
rs.getString("context_json"),
toLocalDateTime(rs.getTimestamp("expires_time"))
), principal.tenantId(), app.id(), principal.userId(), conversationId);
return rows.isEmpty() ? null : rows.get(0);
}
private void purgeExpiredLazily(AihrKnowledgePrincipal principal, AuthenticatedApp app, String conversationId) {
long now = System.currentTimeMillis();
long due = nextCleanupAt.get();
if (now < due || !nextCleanupAt.compareAndSet(due, now + CLEANUP_INTERVAL_MILLIS)) {
return;
}
try {
jdbcTemplate.update("""
delete from aihr_knowledge_conversation
where expires_time <= now()
and not (tenant_id = ? and app_id = ? and user_id = ? and conversation_id = ?)
order by expires_time
limit 500
""", principal.tenantId(), app.id(), principal.userId(), conversationId);
} catch (DataAccessException ex) {
log.debug("knowledge conversation cleanup unavailable(处理错误已隐藏)");
}
}
private RewritePlan rewrite(List<Turn> turns, String queryText, String explicitToolCode) {
RewritePlan fallback = fallbackPlan(turns, queryText, explicitToolCode);
if (turns.isEmpty() || hasText(explicitToolCode)) {
return fallback;
}
String history;
try {
history = objectMapper.writeValueAsString(turns);
} catch (Exception ex) {
return fallback;
}
String userPrompt = truncate("历史对话:" + history + "\n当前问题:" + queryText, MAX_MODEL_CONTEXT);
String systemPrompt = """
你是企业知识问答的查询改写器。只输出 JSON,不要解释:
{"rewrittenQuery":"可独立检索的问题","intent":"QA|FILE|VIDEO|DATA_TOOL","toolCode":null}
只能依据历史对话消解“这个、那份、原文件、相关视频”等指代,不得补造主题。
FILE 表示索要原始文件,VIDEO 表示索要操作视频,DATA_TOOL 只允许 MY_PRACTICE_SUMMARY 或 TEAM_PRACTICE_SUMMARY。
其他问题一律 QA。rewrittenQuery 不超过 600 字。
""";
return modelService.tryChat(systemPrompt, userPrompt, 0.0)
.map(this::parsePlan)
.filter(java.util.Objects::nonNull)
.orElse(fallback);
}
private RewritePlan parsePlan(String raw) {
try {
int start = raw.indexOf('{');
int end = raw.lastIndexOf('}');
if (start < 0 || end <= start) {
return null;
}
JsonNode root = objectMapper.readTree(raw.substring(start, end + 1));
String rewritten = clean(root.path("rewrittenQuery").asText());
String intent = clean(root.path("intent").asText()).toUpperCase(Locale.ROOT);
String toolCode = clean(root.path("toolCode").asText());
if (rewritten.isBlank() || rewritten.length() > MAX_QUESTION || !INTENTS.contains(intent)) {
return null;
}
if ("DATA_TOOL".equals(intent)) {
toolCode = allowedTool(toolCode);
if (toolCode == null) {
return null;
}
} else {
toolCode = null;
}
return new RewritePlan(rewritten, intent, toolCode);
} catch (Exception ex) {
return null;
}
}
private List<Turn> readTurns(String json) {
if (!hasText(json)) {
return List.of();
}
try {
List<Turn> turns = objectMapper.readValue(json, new TypeReference<>() { });
if (turns == null || turns.isEmpty()) {
return List.of();
}
return turns.size() <= MAX_TURNS ? List.copyOf(turns)
: List.copyOf(turns.subList(turns.size() - MAX_TURNS, turns.size()));
} catch (Exception ex) {
log.warn("knowledge conversation context is invalid, resetting current session(处理错误已隐藏)");
return List.of();
}
}
static RewritePlan fallbackPlan(List<Turn> turns, String rawQuery, String explicitToolCode) {
String query = clean(rawQuery);
String explicit = allowedTool(explicitToolCode);
if (explicit != null) {
return new RewritePlan(query, "DATA_TOOL", explicit);
}
String intent = fallbackIntent(query);
String toolCode = "DATA_TOOL".equals(intent) ? fallbackTool(query) : null;
if ("DATA_TOOL".equals(intent) && toolCode == null) {
intent = "QA";
}
String rewritten = query;
if (!turns.isEmpty() && looksLikeFollowup(query, intent)) {
Turn last = turns.get(turns.size() - 1);
String topic = hasText(last.rewrittenQuery()) ? last.rewrittenQuery() : last.question();
if (hasText(topic)) {
rewritten = truncate(topic + ";用户追问:" + query, MAX_QUESTION);
}
}
return new RewritePlan(rewritten, intent, toolCode);
}
private static String fallbackIntent(String query) {
String value = clean(query).toLowerCase(Locale.ROOT);
if (containsAny(value, "视频", "录像", "演示片", "操作片")) {
return "VIDEO";
}
if (containsAny(value, "原文件", "源文件", "原文", "附件", "文件给我", "下载文件", "发我文件")) {
return "FILE";
}
if (fallbackTool(value) != null) {
return "DATA_TOOL";
}
return "QA";
}
private static String fallbackTool(String query) {
String value = clean(query);
if (containsAny(value, "团队训练概况", "团队训练情况", "团队完训")) {
return "TEAM_PRACTICE_SUMMARY";
}
if (containsAny(value, "我的训练概况", "我的训练情况", "我训练了", "我的完训")) {
return "MY_PRACTICE_SUMMARY";
}
return null;
}
private static String allowedTool(String value) {
String tool = clean(value).toUpperCase(Locale.ROOT);
return "MY_PRACTICE_SUMMARY".equals(tool) || "TEAM_PRACTICE_SUMMARY".equals(tool) ? tool : null;
}
private static boolean looksLikeFollowup(String query, String intent) {
return query.length() <= 100 && ("FILE".equals(intent) || "VIDEO".equals(intent)
|| containsAny(query, "这个", "这份", "那个", "那份", "刚才", "上面", "对应", "相关", "它", "那怎么办"));
}
private static boolean containsAny(String value, String... needles) {
for (String needle : needles) {
if (value.contains(needle)) {
return true;
}
}
return false;
}
private static ServiceException conflict() {
return new ServiceException("对话上下文已更新,请刷新后重试", HttpStatus.CONFLICT);
}
private static String sanitize(String value, int max) {
return truncate(AihrSensitiveText.forModel(clean(value)), max);
}
private static LocalDateTime toLocalDateTime(Timestamp timestamp) {
return timestamp == null ? null : timestamp.toLocalDateTime();
}
private static String truncate(String value, int max) {
String text = value == null ? "" : value;
return text.length() <= max ? text : text.substring(0, max);
}
private static String clean(String value) {
return value == null ? "" : value.trim();
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
public record ConversationContext(
String conversationId,
boolean stateful,
boolean existingRow,
long storedVersion,
List<Turn> turns,
String rewrittenQuery,
String intent,
String toolCode
) {
}
public record Turn(
String question,
String rewrittenQuery,
String intent,
String answer,
List<String> docIds,
List<String> titles
) {
public Turn {
docIds = docIds == null ? List.of() : List.copyOf(docIds);
titles = titles == null ? List.of() : List.copyOf(titles);
}
}
record RewritePlan(String rewrittenQuery, String intent, String toolCode) {
}
private record ConversationRow(long version, String contextJson, LocalDateTime expiresTime) {
}
}
@@ -9,6 +9,8 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Citation;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Resource;
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.ConversationContext;
import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.ToolResult;
import org.dromara.aihr.service.AihrSopSeedService;
import org.dromara.common.core.constant.HttpStatus;
@@ -37,17 +39,24 @@ public class AihrKnowledgeQueryService {
private final AihrKnowledgeQueryAuditService auditService;
private final JdbcTemplate jdbcTemplate;
private final AihrKnowledgeDataToolService dataToolService;
private final AihrKnowledgeConversationService conversationService;
public QueryResponse queryInternal(QueryRequest rawRequest) {
QueryRequest request = validate(rawRequest);
AihrKnowledgePrincipal principal = principalResolver.current();
AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey());
if (hasText(request.toolCode())) {
return queryDataTool(principal, app, request);
ConversationContext context = conversationContext(principal, app, request);
QueryRequest routed = routedRequest(request, context);
Set<Long> spaceIds = Set.of();
QueryResponse response;
if (hasText(routed.toolCode())) {
response = queryDataTool(principal, app, routed, request.queryText());
} else {
spaceIds = accessService.resolveInternalSpaceIds(
principal, app, request.spaceCodes(), "READ");
response = queryDocuments(principal, app, spaceIds, routed, request.queryText());
}
Set<Long> spaceIds = accessService.resolveInternalSpaceIds(
principal, app, request.spaceCodes(), "READ");
return queryDocuments(principal, app, spaceIds, request);
return complete(principal, app, spaceIds, request.queryText(), context, response);
}
public QueryResponse queryInternalMedia(QueryRequest rawRequest, MultipartFile file) {
@@ -59,36 +68,59 @@ public class AihrKnowledgeQueryService {
AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey());
Set<Long> spaceIds = accessService.resolveInternalSpaceIds(
principal, app, request.spaceCodes(), "READ");
ConversationContext context = conversationContext(principal, app, request);
String extracted = sopService.extractQuestionMedia(file, request.queryText());
String context = extracted == null ? "" : extracted.trim();
if (context.isBlank()) {
String extractedContext = extracted == null ? "" : extracted.trim();
if (extractedContext.isBlank()) {
throw new ServiceException("附件中没有可用于查询的现场信息", HttpStatus.BAD_REQUEST);
}
String combinedQuery = truncate(request.queryText(), 400)
+ "\n附件现场信息:" + truncate(context, 520);
String combinedQuery = truncate(context.rewrittenQuery(), 400)
+ "\n附件现场信息:" + truncate(extractedContext, 520);
QueryResponse response = queryDocuments(principal, app, spaceIds, new QueryRequest(
combinedQuery, request.spaceCodes(), request.category(), request.position(),
request.source(), request.limit(), null
));
return new QueryResponse(
response.requestId(), request.queryText(), response.answer(), response.citations(),
response.usedSpaceCodes(), response.noEvidence(), response.promptVersion(), response.legacy(), response.data()
);
request.source(), request.limit(), null, request.conversationId(), request.contextVersion()
), request.queryText());
return complete(principal, app, spaceIds, request.queryText(), context, response);
}
public QueryResponse queryExternal(AuthenticatedApp app, QueryRequest rawRequest) {
QueryRequest request = validate(rawRequest);
if (hasText(request.toolCode())) {
throw new ServiceException("外部调用应用不能使用内部数据工具", HttpStatus.FORBIDDEN);
if (hasText(request.toolCode()) || hasText(request.conversationId()) || request.contextVersion() != null) {
throw new ServiceException("外部调用应用不支持数据工具或对话状态", HttpStatus.FORBIDDEN);
}
return TenantHelper.dynamic(app.tenantId(), () -> {
Set<Long> spaceIds = accessService.resolveExternalSpaceIds(app, request.spaceCodes());
return queryDocuments(null, app, spaceIds, request);
return queryDocuments(null, app, spaceIds, request, request.queryText());
});
}
public Long authorizedResourceOssId(Long attachmentId) {
if (attachmentId == null || attachmentId <= 0) {
return null;
}
AihrKnowledgePrincipal principal = principalResolver.current();
AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey());
Set<Long> spaceIds = accessService.resolveInternalSpaceIds(principal, app, List.of(), "READ");
if (spaceIds.isEmpty()) {
return null;
}
List<Object> args = new ArrayList<>();
args.add(principal.tenantId());
args.add(attachmentId);
args.addAll(spaceIds);
List<Long> rows = jdbcTemplate.queryForList("""
select a.oss_id
from aihr_knowledge_attach a
join aihr_knowledge_info k on k.id = a.knowledge_id and k.tenant_id = a.tenant_id
where a.tenant_id = ? and a.id = ? and a.oss_id is not null and a.status = 2
and a.knowledge_id in (%s) and k.status = 'ACTIVE'
limit 1
""".formatted(placeholders(spaceIds.size())), Long.class, args.toArray());
return rows.isEmpty() ? null : rows.get(0);
}
private QueryResponse queryDocuments(AihrKnowledgePrincipal principal, AuthenticatedApp app,
Set<Long> spaceIds, QueryRequest request) {
Set<Long> spaceIds, QueryRequest request, String displayQuery) {
String requestId = UUID.randomUUID().toString().replace("-", "");
long started = System.nanoTime();
List<String> scopeCodes = spaceCodes(app.tenantId(), spaceIds);
@@ -100,12 +132,12 @@ public class AihrKnowledgeQueryService {
boolean noEvidence = citations.isEmpty();
String status = noEvidence ? "NO_EVIDENCE" : "SUCCESS";
long latency = elapsedMillis(started);
auditService.record(requestId, principal, app, request.queryText(), scopeCodes,
auditService.record(requestId, principal, app, displayQuery, scopeCodes,
citations.isEmpty() ? List.of() : List.of("DOCUMENT"), status, latency, legacy.promptVersion());
return new QueryResponse(requestId, request.queryText(), legacy.answer(), citations, scopeCodes,
return new QueryResponse(requestId, displayQuery, legacy.answer(), citations, scopeCodes,
noEvidence, legacy.promptVersion(), legacy, null);
} catch (RuntimeException ex) {
auditService.record(requestId, principal, app, request.queryText(), scopeCodes, List.of(),
auditService.record(requestId, principal, app, displayQuery, scopeCodes, List.of(),
ex instanceof ServiceException serviceEx && serviceEx.getCode() != null && serviceEx.getCode() < 500
? "REJECTED" : "FAILED",
elapsedMillis(started), null);
@@ -113,18 +145,19 @@ public class AihrKnowledgeQueryService {
}
}
private QueryResponse queryDataTool(AihrKnowledgePrincipal principal, AuthenticatedApp app, QueryRequest request) {
private QueryResponse queryDataTool(AihrKnowledgePrincipal principal, AuthenticatedApp app, QueryRequest request,
String displayQuery) {
String requestId = UUID.randomUUID().toString().replace("-", "");
long started = System.nanoTime();
try {
ToolResult result = dataToolService.execute(principal, request.toolCode());
Citation citation = new Citation(null, "DATA_TOOL", result.toolCode(), result.title(), result.window(), null);
auditService.record(requestId, principal, app, request.queryText(), List.of(),
auditService.record(requestId, principal, app, displayQuery, List.of(),
List.of("DATA_TOOL:" + result.toolCode()), "SUCCESS", elapsedMillis(started), "data-tool-v1");
return new QueryResponse(requestId, request.queryText(), result.answer(), List.of(citation), List.of(),
return new QueryResponse(requestId, displayQuery, result.answer(), List.of(citation), List.of(),
false, "data-tool-v1", null, result.data());
} catch (RuntimeException ex) {
auditService.record(requestId, principal, app, request.queryText(), List.of(),
auditService.record(requestId, principal, app, displayQuery, List.of(),
List.of("DATA_TOOL:" + request.toolCode()),
ex instanceof ServiceException serviceEx && serviceEx.getCode() != null && serviceEx.getCode() < 500
? "REJECTED" : "FAILED",
@@ -133,6 +166,83 @@ public class AihrKnowledgeQueryService {
}
}
private QueryResponse complete(AihrKnowledgePrincipal principal, AuthenticatedApp app, Set<Long> spaceIds,
String question, ConversationContext context, QueryResponse response) {
List<Resource> resources = resources(app.tenantId(), spaceIds, response.citations(), context.intent());
String answer = resourceAnswer(context.intent(), resources, response.answer());
boolean noEvidence = isResourceIntent(context.intent()) ? resources.isEmpty() : response.noEvidence();
Long nextVersion = context.stateful()
? conversationService.append(principal, app, context, question, answer, response.citations())
: null;
if (nextVersion == null && context.stateful()) {
nextVersion = context.storedVersion();
}
return new QueryResponse(
response.requestId(), question, answer, response.citations(), response.usedSpaceCodes(), noEvidence,
response.promptVersion(), response.legacy(), response.data(), context.conversationId(), nextVersion,
context.intent(), context.rewrittenQuery(), resources
);
}
private List<Resource> resources(String tenantId, Set<Long> spaceIds, List<Citation> citations, String intent) {
if (!isResourceIntent(intent) || spaceIds.isEmpty() || citations == null || citations.isEmpty()) {
return List.of();
}
List<String> docIds = citations.stream().map(Citation::docId)
.filter(AihrKnowledgeQueryService::hasText).distinct().toList();
if (docIds.isEmpty()) {
return List.of();
}
List<Object> args = new ArrayList<>();
args.add(tenantId);
args.addAll(spaceIds);
args.addAll(docIds);
List<AttachmentResource> rows = jdbcTemplate.query("""
select a.id, a.name
from aihr_knowledge_attach a
join aihr_knowledge_info k on k.id = a.knowledge_id and k.tenant_id = a.tenant_id
where a.tenant_id = ? and a.knowledge_id in (%s) and a.doc_id in (%s)
and a.oss_id is not null and a.status = 2 and k.status = 'ACTIVE'
order by a.id
""".formatted(placeholders(spaceIds.size()), placeholders(docIds.size())),
(rs, rowNum) -> new AttachmentResource(rs.getLong("id"), rs.getString("name")), args.toArray());
return rows.stream()
.filter(row -> "VIDEO".equals(intent) == isVideo(row.title()))
.map(row -> new Resource(row.attachmentId(), row.title(), intent,
"/api/knowledge/resources/" + row.attachmentId() + "/content"))
.distinct()
.limit(5)
.toList();
}
private static String resourceAnswer(String intent, List<Resource> resources, String fallback) {
if ("FILE".equals(intent)) {
return resources.isEmpty()
? "当前授权资料中未找到可交付的相关原文件。"
: "已找到相关原文件,请点击下方资料卡查看或下载。";
}
if ("VIDEO".equals(intent)) {
return resources.isEmpty()
? "当前授权资料中未找到相关操作视频。"
: "已找到相关操作视频,请点击下方视频卡查看。";
}
return fallback;
}
private ConversationContext conversationContext(AihrKnowledgePrincipal principal, AuthenticatedApp app,
QueryRequest request) {
return hasText(request.conversationId())
? conversationService.prepare(principal, app, request)
: AihrKnowledgeConversationService.stateless(request.queryText(), request.toolCode());
}
private static QueryRequest routedRequest(QueryRequest request, ConversationContext context) {
return new QueryRequest(
context.rewrittenQuery(), request.spaceCodes(), request.category(), request.position(), request.source(),
request.limit(), context.toolCode(), request.conversationId(), request.contextVersion()
);
}
private List<Citation> citations(String tenantId, Set<Long> allowedSpaceIds, List<SnippetResponse> snippets) {
if (snippets == null || snippets.isEmpty()) {
return List.of();
@@ -193,7 +303,11 @@ public class AihrKnowledgeQueryService {
if (codes.size() > 20) {
throw new ServiceException("单次最多选择 20 个知识空间", HttpStatus.BAD_REQUEST);
}
return new QueryRequest(query, codes, request.category(), request.position(), request.source(), limit, request.toolCode());
if (request.contextVersion() != null && request.contextVersion() < 0) {
throw new ServiceException("contextVersion 不能小于 0", HttpStatus.BAD_REQUEST);
}
return new QueryRequest(query, codes, request.category(), request.position(), request.source(), limit,
request.toolCode(), request.conversationId(), request.contextVersion());
}
private static long elapsedMillis(long started) {
@@ -212,4 +326,17 @@ public class AihrKnowledgeQueryService {
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
private static boolean isResourceIntent(String intent) {
return "FILE".equals(intent) || "VIDEO".equals(intent);
}
private static boolean isVideo(String title) {
String value = title == null ? "" : title.toLowerCase(java.util.Locale.ROOT);
return value.endsWith(".mp4") || value.endsWith(".mov") || value.endsWith(".avi")
|| value.endsWith(".mkv") || value.endsWith(".webm") || value.endsWith(".m4v");
}
private record AttachmentResource(Long attachmentId, String title) {
}
}
@@ -402,7 +402,7 @@ public class AihrLearningService {
KEY idx_aihr_question_feedback_verdict (tenant_id, verdict, update_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""");
ensureAssignmentColumn("content_version", "ALTER TABLE aihr_practice_assignment ADD COLUMN content_version varchar(30) DEFAULT 'v1' AFTER request_key");
ensureAssignmentColumn("content_version", "ALTER TABLE aihr_practice_assignment ADD COLUMN content_version varchar(30) DEFAULT 'v1'");
ensureAssignmentColumn("question_snapshot", "ALTER TABLE aihr_practice_assignment ADD COLUMN question_snapshot varchar(1000) DEFAULT NULL AFTER content_version");
ensureAssignmentColumn("reference_answer_snapshot", "ALTER TABLE aihr_practice_assignment ADD COLUMN reference_answer_snapshot varchar(2000) DEFAULT NULL AFTER question_snapshot");
ensureAssignmentColumn("content_hash", "ALTER TABLE aihr_practice_assignment ADD COLUMN content_hash char(64) DEFAULT NULL AFTER reference_answer_snapshot");
@@ -2,6 +2,7 @@ package org.dromara.aihr.knowledge;
import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
import org.dromara.aihr.domain.AihrSopDto.SnippetResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
@@ -11,24 +12,30 @@ import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryAuditService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.ConversationContext;
import org.dromara.aihr.service.AihrSopSeedService;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.mock.web.MockMultipartFile;
import java.util.List;
import java.util.Set;
import java.sql.ResultSet;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.eq;
@Tag("dev")
class AihrKnowledgeQueryServiceTest {
@@ -101,7 +108,7 @@ class AihrKnowledgeQueryServiceTest {
List.of(), List.of(), List.of(), List.of(), "test-v1", null));
var service = new AihrKnowledgeQueryService(mock(AihrKnowledgePrincipalResolver.class),
mock(AihrKnowledgeAppService.class), access, sop, mock(AihrKnowledgeQueryAuditService.class), jdbc,
mock(AihrKnowledgeDataToolService.class));
mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class));
var result = service.queryExternal(app,
new QueryRequest("公开服务", List.of("mt_customer_service"), null, null, "miniapp", 5, null));
@@ -121,7 +128,8 @@ class AihrKnowledgeQueryServiceTest {
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(tools.execute(principal, "MY_PRACTICE_SUMMARY")).thenThrow(new ServiceException("tool failed", 500));
var service = new AihrKnowledgeQueryService(resolver, appService, mock(AihrKnowledgeAccessService.class), sop,
mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class), tools);
mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class), tools,
mock(AihrKnowledgeConversationService.class));
assertThrows(ServiceException.class, () -> service.queryInternal(
new QueryRequest("我的训练", List.of(), null, null, "mobile", 5, "MY_PRACTICE_SUMMARY")));
@@ -148,7 +156,8 @@ class AihrKnowledgeQueryServiceTest {
new SearchResponse("现场问题", "sop", "按电梯困人流程处理", "", List.of(), List.of(),
List.of(), List.of(), List.of(), List.of(), "test-v1", null));
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class));
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class));
var result = service.queryInternalMedia(
new QueryRequest("电梯这里该怎么办", List.of(), "sop", "保安", "mobile_uni_media", 5, null),
@@ -161,6 +170,104 @@ class AihrKnowledgeQueryServiceTest {
assertEquals(true, requestCaptor.getValue().queryText().contains("电梯门无法打开"));
}
@Test
void fileFollowupUsesConversationRewriteAndReturnsOnlyAuthorizedAttachment() throws Exception {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var sop = mock(AihrSopSeedService.class);
var jdbc = mock(JdbcTemplate.class);
var conversations = mock(AihrKnowledgeConversationService.class);
var principal = new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1",
Set.of("employee"), Set.of(), "app");
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
var request = new QueryRequest("把这个 SOP 原文件给我", List.of(), "sop", "保安",
"mobile_uni_sop", 5, null, "conversation_123", 1L);
var context = new ConversationContext("conversation_123", true, true, 1L, List.of(),
"自动扶梯困人应急操作 SOP 原文件", "FILE", null);
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(9L));
when(conversations.prepare(principal, app, request)).thenReturn(context);
when(conversations.append(any(), any(), any(), anyString(), anyString(), any())).thenReturn(2L);
when(jdbc.queryForList(any(String.class), any(Class.class), any(Object[].class)))
.thenReturn(List.of("yc_property_sop"));
when(sop.searchAuthorized(any(), any(), any())).thenReturn(
new SearchResponse("原文件", "sop", "已命中", "", List.of(),
List.of(new SnippetResponse("扶梯 SOP", "立即停梯", 88L)),
List.of(), List.of(), List.of(), List.of(), "test-v1", null));
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> {
String sql = invocation.getArgument(0);
RowMapper<?> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
if (sql.contains("from aihr_knowledge_fragment")) {
when(rs.getString("space_code")).thenReturn("yc_property_sop");
when(rs.getString("doc_id")).thenReturn("lift-doc");
when(rs.getString("title")).thenReturn("自动扶梯困人应急操作 SOP.pdf");
when(rs.getString("content")).thenReturn("立即停梯并设置警示");
when(rs.getLong("fragment_id")).thenReturn(88L);
} else {
when(rs.getLong("id")).thenReturn(321L);
when(rs.getString("name")).thenReturn("自动扶梯困人应急操作 SOP.pdf");
}
return List.of(mapper.mapRow(rs, 0));
});
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class), conversations);
var result = service.queryInternal(request);
var searchCaptor = ArgumentCaptor.forClass(SearchRequest.class);
verify(sop).searchAuthorized(searchCaptor.capture(), any(), any());
assertEquals("自动扶梯困人应急操作 SOP 原文件", searchCaptor.getValue().queryText());
assertEquals("FILE", result.intent());
assertEquals(2L, result.contextVersion());
assertEquals(1, result.resources().size());
assertEquals(321L, result.resources().get(0).attachmentId());
assertEquals("/api/knowledge/resources/321/content", result.resources().get(0).contentUrl());
}
@Test
void resourceDownloadRecomputesCurrentApplicationAndPrincipalScope() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var jdbc = mock(JdbcTemplate.class);
var principal = new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1",
Set.of("employee"), Set.of(), "app");
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(9L));
when(jdbc.queryForList(anyString(), eq(Long.class), any(Object[].class))).thenReturn(List.of(777L));
var service = new AihrKnowledgeQueryService(resolver, appService, access, mock(AihrSopSeedService.class),
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class));
assertEquals(777L, service.authorizedResourceOssId(321L));
verify(access).resolveInternalSpaceIds(principal, app, List.of(), "READ");
}
@Test
void resourceDownloadFailsClosedWhenCurrentScopeIsEmpty() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var jdbc = mock(JdbcTemplate.class);
var principal = new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1",
Set.of("employee"), Set.of(), "app");
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of());
var service = new AihrKnowledgeQueryService(resolver, appService, access, mock(AihrSopSeedService.class),
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class));
assertEquals(null, service.authorizedResourceOssId(321L));
verify(jdbc, never()).queryForList(anyString(), eq(Long.class), any(Object[].class));
}
private static AihrKnowledgeQueryService service(
AihrKnowledgePrincipalResolver resolver,
AihrKnowledgeAppService appService,
@@ -168,6 +275,7 @@ class AihrKnowledgeQueryServiceTest {
AihrSopSeedService sop
) {
return new AihrKnowledgeQueryService(resolver, appService, access, sop,
mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class), mock(AihrKnowledgeDataToolService.class));
mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class), mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class));
}
}
@@ -0,0 +1,108 @@
package org.dromara.aihr.knowledge.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.ConversationContext;
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.Turn;
import org.dromara.aihr.service.AihrModelSeedService;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@Tag("dev")
class AihrKnowledgeConversationServiceTest {
@Test
void fileFollowupUsesLastResolvedTopicWhenModelIsUnavailable() {
Turn previous = new Turn("电梯困人怎么办", "自动扶梯困人应急操作 SOP", "QA",
"立即停梯并设置警示。", List.of("lift-1"), List.of("自动扶梯困人应急操作 SOP"));
var plan = AihrKnowledgeConversationService.fallbackPlan(
List.of(previous), "把这个 SOP 原文件给我", null);
assertEquals("FILE", plan.intent());
assertEquals(true, plan.rewrittenQuery().contains("自动扶梯困人应急操作 SOP"));
assertEquals(true, plan.rewrittenQuery().contains("原文件"));
}
@Test
void naturalLanguageDataToolStaysInsideWhitelist() {
var team = AihrKnowledgeConversationService.fallbackPlan(List.of(), "查询团队训练概况", null);
var unrelated = AihrKnowledgeConversationService.fallbackPlan(List.of(), "查询员工工资明细", null);
assertEquals("DATA_TOOL", team.intent());
assertEquals("TEAM_PRACTICE_SUMMARY", team.toolCode());
assertEquals("QA", unrelated.intent());
assertEquals(null, unrelated.toolCode());
}
@Test
void unavailableConversationTableFallsBackToStatelessQuery() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(anyString(), any(org.springframework.jdbc.core.RowMapper.class), any(Object[].class)))
.thenThrow(new DataAccessResourceFailureException("missing table"));
var service = new AihrKnowledgeConversationService(jdbc, new ObjectMapper(), mock(AihrModelSeedService.class));
ConversationContext context = service.prepare(principal(), app(), new QueryRequest(
"电梯怎么办", List.of(), "sop", null, "test", 5, null,
"conversation_123", 0L));
assertFalse(context.stateful());
assertEquals("电梯怎么办", context.rewrittenQuery());
}
@Test
void missingExpiredRowRestartsEvenWhenClientStillHoldsAnOldVersion() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(anyString(), any(org.springframework.jdbc.core.RowMapper.class), any(Object[].class)))
.thenReturn(List.of());
var service = new AihrKnowledgeConversationService(jdbc, new ObjectMapper(), mock(AihrModelSeedService.class));
ConversationContext context = service.prepare(principal(), app(), new QueryRequest(
"重新开始", List.of(), "sop", null, "test", 5, null,
"conversation_123", 9L));
assertTrue(context.stateful());
assertFalse(context.existingRow());
assertEquals(0L, context.storedVersion());
}
@Test
void staleOptimisticWriteIsRejected() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.update(anyString(), any(Object[].class))).thenReturn(0);
var service = new AihrKnowledgeConversationService(jdbc, new ObjectMapper(), mock(AihrModelSeedService.class));
var context = new ConversationContext("conversation_123", true, true, 2L, List.of(),
"电梯怎么办", "QA", null);
ServiceException error = assertThrows(ServiceException.class,
() -> service.append(principal(), app(), context, "电梯怎么办", "先停梯", List.of()));
assertEquals(409, error.getCode());
}
private static AihrKnowledgePrincipal principal() {
return new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
Set.of("employee"), Set.of("P1"), "app");
}
private static AuthenticatedApp app() {
return new AuthenticatedApp(3L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
}
}
@@ -63,6 +63,8 @@ class AihrLearningContractTest {
assertTrue(migration.contains("publish_request_key"));
assertTrue(migration.contains("uk_aihr_onboard_exam_target"));
assertTrue(migration.contains("uk_aihr_onboard_exam_publish_request"));
assertTrue(migration.contains("ADD COLUMN content_version"));
assertTrue(!migration.contains("content_version varchar(30) DEFAULT ''v1'' COMMENT ''派发内容版本'' AFTER request_key"));
}
@Test
@@ -17,7 +17,9 @@ class AihrKnowledgePlatformSchemaTest {
String sql = Files.readString(repoRoot().resolve("backend/script/sql/aihr_knowledge_mysql8.sql"));
String migration = Files.readString(repoRoot().resolve(
"backend/script/sql/update/aihr_20260716_knowledge_space_platform_mysql8.sql"));
String schema = sql + migration;
String conversationMigration = Files.readString(repoRoot().resolve(
"backend/script/sql/update/aihr_20260718_knowledge_conversation_mysql8.sql"));
String schema = sql + migration + conversationMigration;
assertTrue(schema.contains("`code` varchar(100)"));
assertTrue(schema.contains("uk_aihr_knowledge_info_code"));
@@ -25,6 +27,9 @@ class AihrKnowledgePlatformSchemaTest {
assertTrue(schema.contains("aihr_knowledge_app"));
assertTrue(schema.contains("aihr_knowledge_app_space"));
assertTrue(schema.contains("aihr_knowledge_query_log"));
assertTrue(schema.contains("aihr_knowledge_conversation"));
assertTrue(schema.contains("uk_aihr_knowledge_conversation"));
assertTrue(schema.contains("expires_time"));
assertTrue(schema.contains("space_codes_json"));
assertTrue(schema.contains("token_hash"));
assertFalse(schema.contains("token_plain"));