feat(knowledge): add short-term ask memory
This commit is contained in:
+22
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+27
-2
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+371
@@ -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) {
|
||||
}
|
||||
}
|
||||
+154
-27
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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");
|
||||
|
||||
+112
-4
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+108
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
@@ -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
|
||||
|
||||
+6
-1
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user