feat(aihr): add experience library and community best answers as ask sources

- server-side employee position resolution (org snapshot) with hard
  position gate for EXPERIENCE/CASE sources; formal sources unaffected;
  unresolvable position fails closed to general-scope only
- merge published experiences (aihr_case_record, CASE_EXP) and moderated
  best answers (COMMUNITY, no submitter identity) into the citation pool
  as REFERENCE tier; auxiliary sources never alter formal answers and
  fail without breaking the main chain; grading re-grades ROUTE into
  GUIDANCE when only auxiliary evidence exists
- case recall endpoints enforce the same position gate
- moderator endpoint to promote a best answer into a CANDIDATE case
  record (idempotent via source_ref unique key, never auto-published)
- migration aihr_20260822_case_source_ref_and_best_answer_index_mysql8.sql
- COMMUNITY citations carry community-question:{id} in detailRef for
  direct navigation
This commit is contained in:
2026-08-05 02:45:57 +08:00
parent cd3acc90be
commit 8f9c244aeb
22 changed files with 1578 additions and 57 deletions
@@ -19,6 +19,7 @@ import org.dromara.aihr.knowledge.service.AihrEvidenceGradingService;
import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier;
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService.MediaMode;
import org.dromara.aihr.agent.shadow.AihrAgentShadowService;
import org.dromara.aihr.service.AihrEmployeePositionResolver;
import org.springframework.beans.factory.annotation.Autowired;
import org.dromara.aihr.webai.AihrWebAiDto;
import org.dromara.aihr.webai.AihrWebAiService;
@@ -46,6 +47,7 @@ public class AihrAgentOrchestrator {
private final AihrWebAiService webAiService;
private final AihrAgentAuditService auditService;
private final AihrAgentShadowService shadowService;
private final AihrEmployeePositionResolver positionResolver;
private AihrAgentActiveService activeService;
@Autowired
@@ -80,9 +82,10 @@ public class AihrAgentOrchestrator {
false, false, GROUNDED);
}
principal = principalResolver.current();
String position = positionResolver == null ? null : positionResolver.resolvePosition(principal);
response = media
? executeMedia(plan, request, file, principal)
: executeText(plan, request, principal);
? executeMedia(plan, request, file, principal, position)
: executeText(plan, request, principal, position);
return response;
} catch (ServiceException ex) {
errorCode = ex.getCode() == null ? "REJECTED" : "HTTP_" + ex.getCode();
@@ -108,7 +111,8 @@ public class AihrAgentOrchestrator {
}
}
private AgentResponse executeText(AgentPlan plan, AgentRequest request, AihrKnowledgePrincipal principal) {
private AgentResponse executeText(AgentPlan plan, AgentRequest request, AihrKnowledgePrincipal principal,
String position) {
if (plan.requiresExternalConsent() && !request.externalConsent()) {
return simple(plan, AgentStatus.NEEDS_INPUT,
"全网查询会将脱敏后的问题发送给外部公开检索服务。",
@@ -146,32 +150,32 @@ public class AihrAgentOrchestrator {
new Clarification("请补充具体目标", List.of("具体问题")));
case WEB_RESEARCH -> fromWeb(plan, request,
webAiService.query(new AihrWebAiDto.QueryRequest(plan.rewrittenRequest(), true)));
default -> fromQuery(plan, request);
default -> fromQuery(plan, request, position);
};
}
private AgentResponse executeMedia(AgentPlan plan, AgentRequest request, MultipartFile file,
AihrKnowledgePrincipal principal) {
AihrKnowledgePrincipal principal, String position) {
policy.authorize(principal, plan, request.externalConsent());
MediaMode mode = needsKnowledge(request.question())
? MediaMode.MEDIA_WITH_KNOWLEDGE
: MediaMode.MEDIA_ONLY;
String source = agentKnowledgeSource(request.question());
QueryResponse response = queryService.queryInternalMedia(new QueryRequest(
request.question(), List.of(), "sop", null, source, 5, null,
request.question(), List.of(), "sop", position, source, 5, null,
request.conversationId(), request.contextVersion(), request.projectCode(), request.broadcastMessageId()
), file, mode);
return fromResponse(plan, response, "MEDIA");
}
private AgentResponse fromQuery(AgentPlan plan, AgentRequest request) {
private AgentResponse fromQuery(AgentPlan plan, AgentRequest request, String position) {
String toolCode = switch (plan.tool()) {
case MY_CURRENT_TASKS, MY_PRACTICE_SUMMARY, TEAM_PRACTICE_SUMMARY -> plan.tool().name();
default -> null;
};
String source = agentKnowledgeSource(request.question());
QueryResponse response = queryService.queryInternal(new QueryRequest(
plan.rewrittenRequest(), List.of(), "sop", null, source, 5, toolCode,
plan.rewrittenRequest(), List.of(), "sop", position, source, 5, toolCode,
request.conversationId(), request.contextVersion(), request.projectCode(), request.broadcastMessageId()
));
return fromResponse(plan, response, toolCode == null ? "ENTERPRISE_KNOWLEDGE" : "LIVE_DATA");
@@ -87,4 +87,19 @@ public final class AihrCommunityDto {
int studyCreditBalance,
List<AihrIncentiveService.IncentiveRuleResponse> rules) {
}
/**
* Read-only best-answer match for the knowledge auxiliary source. Deliberately carries no
* submitter identity fields; the employee-facing citation only quotes the question title and
* the accepted answer content.
*/
public record BestAnswerMatch(Long questionId,
Long answerId,
String questionTitle,
String answerContent) {
}
public record CaseCandidateResponse(String caseId,
boolean created) {
}
}
@@ -3,7 +3,9 @@ package org.dromara.aihr.community;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.community.AihrCommunityDto.AnswerResponse;
import org.dromara.aihr.community.AihrCommunityDto.AnswerFeedbackQuestionRequest;
import org.dromara.aihr.community.AihrCommunityDto.BestAnswerMatch;
import org.dromara.aihr.community.AihrCommunityDto.BestAnswerResponse;
import org.dromara.aihr.community.AihrCommunityDto.CaseCandidateResponse;
import org.dromara.aihr.community.AihrCommunityDto.CreateAnswerRequest;
import org.dromara.aihr.community.AihrCommunityDto.CreateQuestionRequest;
import org.dromara.aihr.community.AihrCommunityDto.PointsEntryResponse;
@@ -12,7 +14,9 @@ import org.dromara.aihr.community.AihrCommunityDto.QuestionResponse;
import org.dromara.aihr.community.AihrCommunityDto.ReviewRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.knowledge.service.AihrRagQueryPlanner;
import org.dromara.aihr.service.AihrSchemaMigrationGuard;
import org.dromara.aihr.service.AihrSensitiveText;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -32,6 +36,7 @@ import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
@Service
@RequiredArgsConstructor
@@ -343,6 +348,184 @@ public class AihrCommunityService {
return principalResolver.current();
}
/**
* Read-only best-answer retrieval for the knowledge auxiliary source. Only accepted answers
* of visible questions in the same tenant are matched against the question title/content;
* no submitter identity column is ever selected.
*/
public List<BestAnswerMatch> searchBestAnswers(String tenantId, String queryText, int limit) {
String tenant = clean(tenantId);
List<String> terms = AihrRagQueryPlanner.sparseTerms(queryText).stream().limit(5).toList();
if (tenant.isBlank() || terms.isEmpty()) {
return List.of();
}
int safeLimit = limit <= 0 ? 3 : Math.min(limit, 5);
String matchExpr = "CONCAT_WS(' ', COALESCE(q.title, ''), COALESCE(q.content, ''))";
String hits = terms.stream()
.map(term -> "(CASE WHEN " + matchExpr + " LIKE ? ESCAPE '\\\\' THEN 1 ELSE 0 END)")
.collect(java.util.stream.Collectors.joining(" + "));
String termWhere = terms.stream()
.map(term -> matchExpr + " LIKE ? ESCAPE '\\\\'")
.collect(java.util.stream.Collectors.joining(" OR "));
List<Object> args = new ArrayList<>();
args.add(tenant);
terms.forEach(term -> args.add(likePattern(term)));
terms.forEach(term -> args.add(likePattern(term)));
args.add(safeLimit);
return jdbcTemplate.query("""
SELECT a.id AS answer_id, q.id AS question_id, q.title, a.content
FROM aihr_community_answer a
JOIN aihr_community_question q
ON q.tenant_id = a.tenant_id AND q.id = a.question_id
WHERE a.tenant_id = ?
AND a.answer_status = 'PUBLISHED'
AND a.is_best = 1
AND q.question_status IN ('PUBLISHED', 'CLOSED')
AND (%s)
ORDER BY (%s) DESC, a.id DESC
LIMIT ?
""".formatted(termWhere, hits),
(rs, rowNum) -> new BestAnswerMatch(
rs.getLong("question_id"), rs.getLong("answer_id"),
rs.getString("title"), rs.getString("content")),
args.toArray());
}
/**
* Moderator-triggered conversion of an accepted best answer into an experience candidate.
* The candidate always enters the manual review chain: knowledge_status stays CANDIDATE and
* source_authorized/deidentified stay 0, so it can never become employee-visible without the
* formal publish confirmation. Idempotent per answer: the same answerId maps to one case.
*/
@Transactional(rollbackFor = Exception.class)
public CaseCandidateResponse toCaseCandidate(Long answerId) {
AihrKnowledgePrincipal principal = requireModerator();
ensureTables();
ensureCaseSourceRef();
if (answerId == null || answerId < 1) {
throw new IllegalArgumentException("回答不存在");
}
List<BestAnswerSource> rows = jdbcTemplate.query("""
select a.id, a.question_id, a.content, a.answer_status, a.is_best,
q.title, q.category
from aihr_community_answer a
join aihr_community_question q
on q.tenant_id = a.tenant_id and q.id = a.question_id
where a.tenant_id = ? and a.id = ?
""", (rs, rowNum) -> new BestAnswerSource(
rs.getLong("id"), rs.getLong("question_id"), rs.getString("content"),
rs.getString("answer_status"), rs.getBoolean("is_best"),
rs.getString("title"), rs.getString("category")),
principal.tenantId(), answerId);
if (rows.isEmpty()) {
throw new IllegalArgumentException("回答不存在");
}
BestAnswerSource answer = rows.get(0);
if (!answer.best() || !"PUBLISHED".equals(answer.status())) {
throw new IllegalArgumentException("仅已发布且被评为最佳的回答可转为经验候选");
}
String sourceMarker = "community-answer:" + answerId;
List<String> existing = jdbcTemplate.query("""
select case_id from aihr_case_record
where tenant_id = ? and file_name = ?
limit 1
""", (rs, rowNum) -> rs.getString("case_id"), principal.tenantId(), sourceMarker);
if (!existing.isEmpty()) {
return new CaseCandidateResponse(existing.get(0), false);
}
String caseId = "case-" + UUID.randomUUID();
try {
jdbcTemplate.update("""
insert into aihr_case_record
(tenant_id, case_id, file_name, project_ext_org_id, title, type, status, knowledge_status,
source_type, source_ref, transcript, summary, applicable_positions,
source_authorized, deidentified, owner, create_time, update_time)
values (?, ?, ?, '', ?, '问题榜最佳答案', '待审核', 'CANDIDATE', 'COMMUNITY_QA', ?, ?, ?, ?, 0, 0,
'问题榜', now(), now())
""", principal.tenantId(), caseId, sourceMarker,
truncate(clean(answer.questionTitle()), 120),
String.valueOf(answerId),
truncate(AihrSensitiveText.forModel(answer.content()), 4000),
truncate(AihrSensitiveText.forModel(answer.questionTitle()), 1000),
positionForCategory(answer.category()));
} catch (DuplicateKeyException ignored) {
List<String> replay = jdbcTemplate.query("""
select case_id from aihr_case_record
where tenant_id = ? and file_name = ?
limit 1
""", (rs, rowNum) -> rs.getString("case_id"), principal.tenantId(), sourceMarker);
if (replay.isEmpty()) {
throw new IllegalStateException("经验候选创建冲突,请稍后重试");
}
return new CaseCandidateResponse(replay.get(0), false);
}
return new CaseCandidateResponse(caseId, true);
}
/**
* Best-effort category-to-position hint for community-sourced candidates. When no catalog
* position can be determined the scope stays empty (universal); the formal publish flow
* requires a human to confirm the applicable positions anyway.
*/
static String positionForCategory(String category) {
String value = clean(category);
if (value.isBlank()) {
return "";
}
if (value.contains("保安") || value.contains("秩序") || value.contains("安防")) {
return "保安";
}
if (value.contains("保洁") || value.contains("环境") || value.contains("绿化")) {
return "保洁";
}
if (value.contains("保修") || value.contains("维修") || value.contains("工程") || value.contains("报修")) {
return "保修";
}
if (value.contains("客服") || value.contains("管家")) {
return "客服";
}
if (value.contains("生活顾问") || value.contains("顾问")) {
return "生活顾问";
}
return "";
}
private void ensureCaseSourceRef() {
if (!runtimeSchemaBootstrap) {
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_case_record");
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_case_record", "source_ref");
AihrSchemaMigrationGuard.requireIndexDefinition(jdbcTemplate, "aihr_case_record",
"uk_aihr_case_record_source", true, "tenant_id", "source_type", "source_ref");
return;
}
Integer columnCount = jdbcTemplate.queryForObject("""
select count(*) from information_schema.columns
where table_schema = database() and table_name = 'aihr_case_record' and column_name = 'source_ref'
""", Integer.class);
if (columnCount == null || columnCount == 0) {
jdbcTemplate.execute("""
ALTER TABLE `aihr_case_record`
ADD COLUMN `source_ref` varchar(80) DEFAULT NULL COMMENT '经验来源引用(如问题榜回答ID)' AFTER `source_type`
""");
}
Integer indexCount = jdbcTemplate.queryForObject("""
select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'aihr_case_record'
and index_name = 'uk_aihr_case_record_source'
""", Integer.class);
if (indexCount == null || indexCount == 0) {
jdbcTemplate.execute("""
ALTER TABLE `aihr_case_record`
ADD UNIQUE KEY `uk_aihr_case_record_source` (`tenant_id`, `source_type`, `source_ref`)
""");
}
}
private static String likePattern(String term) {
String escaped = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
return "%" + escaped + "%";
}
private QuestionResponse response(AihrKnowledgePrincipal principal, QuestionRow row, boolean includeModeration) {
List<AnswerResponse> answers = answers(principal, row.id(), includeModeration);
return new QuestionResponse(row.id(), row.type(), row.category(), row.title(), row.content(), row.authorName(),
@@ -567,6 +750,8 @@ public class AihrCommunityService {
"uk_aihr_community_question_submit_key", true,
"tenant_id", "author_user_id", "submit_idempotency_key");
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_points_ledger", "account_type");
AihrSchemaMigrationGuard.requireIndexDefinition(jdbcTemplate, "aihr_community_answer",
"idx_aihr_community_answer_best", false, "tenant_id", "is_best", "answer_status");
tablesReady = true;
return;
}
@@ -587,6 +772,17 @@ public class AihrCommunityService {
""");
}
ensureQuestionBridgeSchema();
Integer bestIndexCount = jdbcTemplate.queryForObject("""
select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'aihr_community_answer'
and index_name = 'idx_aihr_community_answer_best'
""", Integer.class);
if (bestIndexCount == null || bestIndexCount == 0) {
jdbcTemplate.execute("""
ALTER TABLE `aihr_community_answer`
ADD KEY `idx_aihr_community_answer_best` (`tenant_id`, `is_best`, `answer_status`)
""");
}
tablesReady = true;
}
}
@@ -623,7 +819,8 @@ public class AihrCommunityService {
`moderation_note` varchar(300) DEFAULT NULL, `moderated_time` datetime DEFAULT NULL,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), KEY `idx_aihr_community_answer_question` (`tenant_id`, `question_id`, `answer_status`, `create_time`)
PRIMARY KEY (`id`), KEY `idx_aihr_community_answer_question` (`tenant_id`, `question_id`, `answer_status`, `create_time`),
KEY `idx_aihr_community_answer_best` (`tenant_id`, `is_best`, `answer_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""");
ddls.add("""
@@ -777,4 +974,8 @@ public class AihrCommunityService {
private record LinkedQuestion(Long questionId, String sourceRequestId) {
}
private record BestAnswerSource(Long id, Long questionId, String content, String status, boolean best,
String questionTitle, String category) {
}
}
@@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor;
import org.dromara.aihr.community.AihrCommunityDto.AnswerResponse;
import org.dromara.aihr.community.AihrCommunityDto.BestAnswerRequest;
import org.dromara.aihr.community.AihrCommunityDto.BestAnswerResponse;
import org.dromara.aihr.community.AihrCommunityDto.CaseCandidateResponse;
import org.dromara.aihr.community.AihrCommunityDto.CreateQuestionRequest;
import org.dromara.aihr.community.AihrCommunityDto.QuestionResponse;
import org.dromara.aihr.community.AihrCommunityDto.ReviewRequest;
@@ -54,4 +55,13 @@ public class AihrQuestionAdminController {
}
return R.ok(communityService.chooseBest(id, request.answerId(), true));
}
/**
* 最佳答案转经验候选:只创建 CANDIDATE 候选(source_authorized/deidentified 保持 0),
* 正式发布仍走案例库人工确认链;同一 answerId 幂等返回同一 caseId。
*/
@PostMapping("/answers/{id}/to-case-candidate")
public R<CaseCandidateResponse> toCaseCandidate(@PathVariable Long id) {
return R.ok(communityService.toCaseCandidate(id));
}
}
@@ -81,7 +81,7 @@ public class AihrCaseController {
if (!canViewCases()) {
return R.fail("无权查看案例素材");
}
return R.ok(caseService.records(limit, currentProjectScopes()));
return R.ok(caseService.records(limit, currentProjectScopes(), currentAppPositions()));
}
@GetMapping("/records/{caseId}")
@@ -89,7 +89,7 @@ public class AihrCaseController {
if (!canViewCases()) {
return R.fail("无权查看案例素材");
}
DetailResponse detail = caseService.detail(caseId, currentProjectScopes());
DetailResponse detail = caseService.detail(caseId, currentProjectScopes(), currentAppPositions());
return detail == null ? R.fail("案例不存在或无权访问") : R.ok(detail);
}
@@ -99,7 +99,7 @@ public class AihrCaseController {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权查看案例音频");
return;
}
Long mediaOssId = caseService.authorizedMediaOssId(caseId, currentProjectScopes());
Long mediaOssId = caseService.authorizedMediaOssId(caseId, currentProjectScopes(), currentAppPositions());
if (mediaOssId == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "案例音频不存在或无权访问");
return;
@@ -150,6 +150,22 @@ public class AihrCaseController {
return caseService.projectScopesForAppUser(currentAppMobilePhone(loginUser));
}
/**
* Server-side position scope for the experience position gate. Management-side viewers keep
* the null (ungated) semantics; APP employees are resolved from the org snapshot and an
* unresolved position only matches universal (empty-scope) experience.
*/
private List<String> currentAppPositions() {
LoginUser loginUser = LoginHelper.getLoginUser();
if (loginUser != null && UserType.SYS_USER.getUserType().equals(loginUser.getUserType())) {
return null;
}
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
return List.of();
}
return caseService.positionsForAppUser(currentAppMobilePhone(loginUser));
}
private boolean canManageCases() {
LoginUser loginUser = LoginHelper.getLoginUser();
if (loginUser != null && UserType.SYS_USER.getUserType().equals(loginUser.getUserType())) {
@@ -20,17 +20,23 @@ import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.Conve
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.PendingClarification;
import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.ToolResult;
import org.dromara.aihr.knowledge.quality.AihrKnowledgeGlossaryService;
import org.dromara.aihr.community.AihrCommunityDto.BestAnswerMatch;
import org.dromara.aihr.community.AihrCommunityService;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse;
import org.dromara.aihr.memory.AihrMemoryDto.SourceSnapshot;
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecall;
import org.dromara.aihr.memory.AihrMemoryService;
import org.dromara.aihr.service.AihrEmployeePositionResolver;
import org.dromara.aihr.service.AihrModelSeedService;
import org.dromara.aihr.service.AihrPositionGate;
import org.dromara.aihr.service.AihrSensitiveText;
import org.dromara.aihr.service.AihrSopSeedService;
import org.dromara.aihr.service.AihrSopSeedService.SearchExecution;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.enums.UserType;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.tenant.helper.TenantHelper;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -68,6 +74,8 @@ public class AihrKnowledgeQueryService {
private final AihrModelSeedService modelService;
private final AihrKnowledgeCitationDetailService citationDetailService;
private final AihrKnowledgeGlossaryService glossaryService;
private final AihrEmployeePositionResolver positionResolver;
private final AihrCommunityService communityService;
@Autowired
public AihrKnowledgeQueryService(AihrKnowledgePrincipalResolver principalResolver,
@@ -82,7 +90,9 @@ public class AihrKnowledgeQueryService {
AihrBroadcastService broadcastService,
AihrModelSeedService modelService,
AihrKnowledgeCitationDetailService citationDetailService,
AihrKnowledgeGlossaryService glossaryService) {
AihrKnowledgeGlossaryService glossaryService,
AihrEmployeePositionResolver positionResolver,
AihrCommunityService communityService) {
this.principalResolver = principalResolver;
this.appService = appService;
this.accessService = accessService;
@@ -96,6 +106,26 @@ public class AihrKnowledgeQueryService {
this.modelService = modelService;
this.citationDetailService = citationDetailService;
this.glossaryService = glossaryService;
this.positionResolver = positionResolver;
this.communityService = communityService;
}
public AihrKnowledgeQueryService(AihrKnowledgePrincipalResolver principalResolver,
AihrKnowledgeAppService appService,
AihrKnowledgeAccessService accessService,
AihrSopSeedService sopService,
AihrKnowledgeQueryAuditService auditService,
JdbcTemplate jdbcTemplate,
AihrKnowledgeDataToolService dataToolService,
AihrKnowledgeConversationService conversationService,
AihrMemoryService memoryService,
AihrBroadcastService broadcastService,
AihrModelSeedService modelService,
AihrKnowledgeCitationDetailService citationDetailService,
AihrKnowledgeGlossaryService glossaryService) {
this(principalResolver, appService, accessService, sopService, auditService, jdbcTemplate,
dataToolService, conversationService, memoryService, broadcastService, modelService,
citationDetailService, glossaryService, null, null);
}
public AihrKnowledgeQueryService(AihrKnowledgePrincipalResolver principalResolver,
@@ -138,6 +168,7 @@ public class AihrKnowledgeQueryService {
QueryRequest request = validate(rawRequest);
AihrKnowledgePrincipal principal = projectPrincipal(principalResolver.current(), request.projectCode());
AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey());
List<String> positions = resolvePositions(principal);
ConversationContext context = conversationContext(principal, app, request);
QueryRequest routed = routedRequest(request, context);
Set<Long> spaceIds = Set.of();
@@ -164,7 +195,7 @@ public class AihrKnowledgeQueryService {
request.queryText(),
spaceIds.isEmpty()
? emptyProjectResponse(request.queryText())
: queryDocuments(principal, app, spaceIds, routed, request.queryText())
: queryDocuments(principal, app, spaceIds, routed, request.queryText(), positions)
);
// Revalidate immediately before the response can be persisted into the short
// conversation. A withdrawal (or an employee becoming ineligible) during the
@@ -201,13 +232,19 @@ public class AihrKnowledgeQueryService {
if (response == null) {
response = candidateIntent
? emptyProjectResponse(request.queryText())
: queryDocuments(principal, app, spaceIds, routed, request.queryText());
: queryDocuments(principal, app, spaceIds, routed, request.queryText(), positions);
finalizeAudit = candidateIntent;
}
if (!memoryRecall.items().isEmpty()) {
response = mergeServiceMemories(memoryRecall, response);
finalizeAudit = true;
}
List<Citation> auxiliary = auxiliaryEvidenceCitations(
app.tenantId(), principal, positions, routed, context, candidateIntent);
if (!auxiliary.isEmpty()) {
response = mergeAuxiliaryEvidence(response, auxiliary);
finalizeAudit = true;
}
}
MemoryCandidateResponse candidate = context.broadcastMessageId() != null
|| !"QA".equals(context.intent()) || hasText(routed.toolCode())
@@ -289,14 +326,15 @@ public class AihrKnowledgeQueryService {
List<SnippetResponse> snippets = evidenceResponse == null ? List.of() : evidenceResponse.snippets();
long citationGateStarted = System.nanoTime();
List<Citation> citations = withCitationIds(requestId, citations(
app.tenantId(), spaceIds, snippets, formalPolicyOnly, request.queryText()));
app.tenantId(), spaceIds, snippets, formalPolicyOnly, request.queryText(),
resolvePositions(principal)));
Map<String, Long> timings = new LinkedHashMap<>(execution == null ? Map.of() : execution.timings());
timings.put("CITATION_GATE", elapsedMillis(citationGateStarted));
List<RetrievalCandidate> candidates = execution == null ? List.of() : execution.candidates();
String status = citations.isEmpty() ? "NO_EVIDENCE" : "EVIDENCE_READY";
long auditStarted = System.nanoTime();
auditService.record(requestId, principal, app, request.queryText(), scopeCodes,
citations.isEmpty() ? List.of() : List.of("DOCUMENT"), status,
citationSourceTypes(citations), status,
elapsedMillis(started), "evidence-only-v1");
auditService.recordEvidence(requestId, app.tenantId(), snippets, citations.stream()
.map(Citation::fragmentId).filter(Objects::nonNull).toList());
@@ -352,6 +390,7 @@ public class AihrKnowledgeQueryService {
}
AihrKnowledgePrincipal principal = projectPrincipal(principalResolver.current(), request.projectCode());
AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey());
List<String> positions = resolvePositions(principal);
Set<Long> spaceIds = mode == MediaMode.MEDIA_ONLY
? Set.of()
: accessService.resolveInternalSpaceIds(principal, app, request.spaceCodes(), "READ");
@@ -380,7 +419,7 @@ public class AihrKnowledgeQueryService {
combinedQuery, request.spaceCodes(), request.category(), request.position(),
request.source(), request.limit(), null, request.conversationId(), request.contextVersion(),
request.projectCode()
), request.queryText());
), request.queryText(), positions);
response = withMediaContextWhenNoEvidence(response, extractedContext);
}
QueryResponse completed = complete(principal, app, spaceIds, request.queryText(), context, response);
@@ -417,7 +456,7 @@ public class AihrKnowledgeQueryService {
}
return TenantHelper.dynamic(app.tenantId(), () -> {
Set<Long> spaceIds = accessService.resolveExternalSpaceIds(app, request.spaceCodes());
return queryDocuments(null, app, spaceIds, request, request.queryText());
return queryDocuments(null, app, spaceIds, request, request.queryText(), List.of());
});
}
@@ -474,7 +513,8 @@ public class AihrKnowledgeQueryService {
}
private QueryResponse queryDocuments(AihrKnowledgePrincipal principal, AuthenticatedApp app,
Set<Long> spaceIds, QueryRequest request, String displayQuery) {
Set<Long> spaceIds, QueryRequest request, String displayQuery,
List<String> positions) {
String requestId = UUID.randomUUID().toString().replace("-", "");
long started = System.nanoTime();
List<String> scopeCodes = spaceCodes(app.tenantId(), spaceIds);
@@ -502,7 +542,7 @@ public class AihrKnowledgeQueryService {
}
SearchResponse legacy = execution.response();
List<Citation> citations = citations(
app.tenantId(), spaceIds, legacy.snippets(), formalPolicyOnly, request.queryText());
app.tenantId(), spaceIds, legacy.snippets(), formalPolicyOnly, request.queryText(), positions);
QueryPlan plan = execution.queryPlan() != null ? execution.queryPlan()
: AihrRagQueryPlanner.plan(request.queryText());
// 降级阶梯仲裁单点:noEvidence、答案文案和证据分级全部来自 grading 结果。
@@ -516,7 +556,7 @@ public class AihrKnowledgeQueryService {
String status = noEvidence ? "NO_EVIDENCE" : needsClarification ? "NEEDS_CLARIFICATION" : "SUCCESS";
long latency = elapsedMillis(started);
auditService.record(requestId, principal, app, displayQuery, scopeCodes,
citations.isEmpty() ? List.of() : List.of("DOCUMENT"), status, latency, gradedLegacy.promptVersion());
citationSourceTypes(citations), status, latency, gradedLegacy.promptVersion());
auditService.recordEvidence(requestId, app.tenantId(), gradedLegacy.snippets(), citations.stream()
.map(Citation::fragmentId).filter(Objects::nonNull).toList());
auditService.recordCandidates(requestId, app.tenantId(), execution.candidates());
@@ -822,6 +862,166 @@ public class AihrKnowledgeQueryService {
);
}
private List<String> resolvePositions(AihrKnowledgePrincipal principal) {
if (positionResolver == null || principal == null) {
return List.of();
}
return positionResolver.resolvePositions(principal);
}
/**
* Auxiliary read-only answer sources for QA questions: published experience-library cases
* (position-gated) and community best answers. Both are REFERENCE-tier evidence; any failure
* in an auxiliary source must never break the main retrieval chain.
*/
private List<Citation> auxiliaryEvidenceCitations(String tenantId, AihrKnowledgePrincipal principal,
List<String> positions, QueryRequest routed,
ConversationContext context, boolean candidateIntent) {
if (principal == null || candidateIntent || !"QA".equals(context.intent())) {
return List.of();
}
List<Citation> auxiliary = new ArrayList<>();
auxiliary.addAll(publishedExperienceCitations(tenantId, positions, routed.queryText()));
auxiliary.addAll(communityBestAnswerCitations(tenantId, routed.queryText()));
return List.copyOf(auxiliary);
}
private List<Citation> publishedExperienceCitations(String tenantId, List<String> positions, String queryText) {
List<String> terms = AihrRagQueryPlanner.sparseTerms(queryText).stream().limit(5).toList();
if (terms.isEmpty()) {
return List.of();
}
try {
String matchExpr = "CONCAT_WS(' ', COALESCE(title, ''), COALESCE(summary, ''), COALESCE(transcript, ''))";
String hits = terms.stream()
.map(term -> "(CASE WHEN " + matchExpr + " LIKE ? ESCAPE '\\\\' THEN 1 ELSE 0 END)")
.collect(java.util.stream.Collectors.joining(" + "));
String termWhere = terms.stream()
.map(term -> matchExpr + " LIKE ? ESCAPE '\\\\'")
.collect(java.util.stream.Collectors.joining(" OR "));
List<Object> args = new ArrayList<>();
args.add(tenantId);
String positionGate = AihrPositionGate.applicableScopeSql("applicable_positions", positions);
args.addAll(AihrPositionGate.normalizedPositions(positions));
terms.forEach(term -> args.add(likePattern(term)));
terms.forEach(term -> args.add(likePattern(term)));
return jdbcTemplate.query("""
SELECT case_id, title, summary, content_version, effective_from
FROM aihr_case_record
WHERE tenant_id = ?
AND status = '已入库'
AND knowledge_status = 'PUBLISHED'
AND (effective_from IS NULL OR effective_from <= CURRENT_DATE())
AND (effective_to IS NULL OR effective_to >= CURRENT_DATE())
AND %s
AND (%s)
ORDER BY (%s) DESC, update_time DESC, id DESC
LIMIT 3
""".formatted(positionGate, termWhere, hits),
(rs, rowNum) -> new Citation(
null, CASE_EXP_SOURCE_TYPE, "case:" + rs.getString("case_id"),
hasText(rs.getString("title")) ? rs.getString("title") : "现场经验",
truncate(masked(firstNonBlank(rs.getString("summary"), "")), 500),
null, CASE_EXP_SOURCE_TYPE, "PUBLISHED", null, null,
null, null, null, "EXPERIENCE_LIBRARY", "CASE", null,
rs.getString("content_version"),
rs.getDate("effective_from") == null ? null : rs.getDate("effective_from").toString(),
null, AihrEvidenceGradingService.TIER_REFERENCE
), args.toArray());
} catch (DataAccessException ex) {
// 经验库是增强来源:表结构未迁移或查询失败时不得影响主检索链。
return List.of();
}
}
private List<Citation> communityBestAnswerCitations(String tenantId, String queryText) {
if (communityService == null) {
return List.of();
}
List<BestAnswerMatch> matches;
try {
matches = communityService.searchBestAnswers(tenantId, queryText, 3);
} catch (RuntimeException ex) {
// 问题榜是增强来源:任何失败都不得影响主检索链。
return List.of();
}
if (matches == null || matches.isEmpty()) {
return List.of();
}
return matches.stream()
.map(match -> new Citation(
null, COMMUNITY_SOURCE_TYPE, "community-answer:" + match.answerId(),
hasText(match.questionTitle()) ? match.questionTitle() : "问题榜最佳答案",
truncate(masked(match.answerContent()), 500),
null, COMMUNITY_SOURCE_TYPE, "PUBLISHED", null, null,
null, match.questionId() == null ? null : "community-question:" + match.questionId(), null, "COMMUNITY_BEST_ANSWER", "EXPERIENCE", null,
null, null, null, AihrEvidenceGradingService.TIER_REFERENCE
))
.toList();
}
/**
* Appends auxiliary REFERENCE citations after the main retrieval. The grading single point
* decides the visible answer shape: with existing evidence the answer is untouched; with no
* document evidence the auxiliary citations are re-graded into GUIDANCE instead of ROUTE.
*/
private QueryResponse mergeAuxiliaryEvidence(QueryResponse response, List<Citation> auxiliary) {
List<Citation> citations = new ArrayList<>(response.citations());
auxiliary.stream()
.filter(citation -> citations.stream()
.noneMatch(existing -> Objects.equals(existing.docId(), citation.docId())))
.forEach(citations::add);
if (citations.size() == response.citations().size()) {
return response;
}
if (!response.noEvidence()) {
return new QueryResponse(
response.requestId(), response.queryText(), response.answer(), List.copyOf(citations),
response.usedSpaceCodes(), response.noEvidence(), response.promptVersion(), response.legacy(),
response.data(), response.conversationId(), response.contextVersion(), response.intent(),
response.rewrittenQuery(), response.resources(), response.memoryCandidate(),
response.broadcastContext(), response.queryPlan(), response.retrievalCandidates(),
response.evidenceLevel(), response.uncoveredPoints(), response.nextActions()
);
}
String domain = response.queryPlan() == null ? null : response.queryPlan().domain();
AihrEvidenceGradingService.GradedAnswer graded = AihrEvidenceGradingService.grade(
domain, citations, response.legacy());
SearchResponse legacy = response.legacy() == null ? null
: response.legacy().withGradedAnswer(graded.answer(), graded.keyPoints(), graded.cautions());
return new QueryResponse(
response.requestId(), response.queryText(), graded.answer(), List.copyOf(citations),
response.usedSpaceCodes(), graded.noEvidence(), response.promptVersion(), legacy,
response.data(), response.conversationId(), response.contextVersion(), response.intent(),
response.rewrittenQuery(), response.resources(), response.memoryCandidate(),
response.broadcastContext(), response.queryPlan(), response.retrievalCandidates(),
graded.evidenceLevel(), graded.uncoveredPoints(), graded.nextActions()
);
}
private static List<String> citationSourceTypes(List<Citation> citations) {
if (citations == null || citations.isEmpty()) {
return List.of();
}
LinkedHashSet<String> sourceTypes = new LinkedHashSet<>();
citations.stream().map(Citation::sourceType).filter(AihrKnowledgeQueryService::hasText)
.forEach(sourceTypes::add);
return List.copyOf(sourceTypes);
}
private static String likePattern(String term) {
String escaped = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
return "%" + escaped + "%";
}
private static String masked(String value) {
return AihrSensitiveText.forModel(value);
}
private static String firstNonBlank(String value, String fallback) {
return hasText(value) ? value : fallback;
}
private static QueryResponse emptyProjectResponse(String queryText) {
return new QueryResponse(
UUID.randomUUID().toString().replace("-", ""), queryText, "", List.of(), List.of(),
@@ -832,14 +1032,9 @@ public class AihrKnowledgeQueryService {
private static List<String> finalSourceTypes(QueryResponse response, ServiceMemoryRecall recall,
MemoryCandidateResponse candidate) {
LinkedHashSet<String> sourceTypes = new LinkedHashSet<>();
boolean hasDocument = response.citations().stream()
.anyMatch(citation -> "DOCUMENT".equals(citation.sourceType()));
if (hasDocument) {
sourceTypes.add("DOCUMENT");
}
if (response.citations().stream().anyMatch(citation -> "BCAST".equals(citation.sourceType()))) {
sourceTypes.add("BCAST");
}
response.citations().stream().map(Citation::sourceType)
.filter(AihrKnowledgeQueryService::hasText)
.forEach(sourceTypes::add);
if (recall != null && !recall.items().isEmpty()) {
recall.items().stream().map(item -> hasText(item.domain()) ? item.domain() : PROJECT_SERVICE)
.forEach(sourceTypes::add);
@@ -914,7 +1109,7 @@ public class AihrKnowledgeQueryService {
}
private List<Citation> citations(String tenantId, Set<Long> allowedSpaceIds, List<SnippetResponse> snippets,
boolean formalPolicyOnly, String queryText) {
boolean formalPolicyOnly, String queryText, List<String> positions) {
if (snippets == null || snippets.isEmpty()) {
return List.of();
}
@@ -928,7 +1123,7 @@ public class AihrKnowledgeQueryService {
args.addAll(allowedSpaceIds);
args.addAll(fragmentIds);
String formalSourceClause = "";
List<Citation> rows = jdbcTemplate.query("""
List<GovernedCitation> rows = jdbcTemplate.query("""
select k.code as space_code, f.doc_id,
coalesce((
select governed_version.redacted_source_name
@@ -949,7 +1144,8 @@ public class AihrKnowledgeQueryService {
governed_asset.source_version, governed_asset.effective_from,
concat_ws('|', nullif(governed_asset.applicable_region, ''),
nullif(governed_asset.applicable_project, ''), nullif(governed_asset.applicable_role, ''))
as applicability
as applicability,
governed_asset.applicable_role
from aihr_knowledge_fragment f
join aihr_knowledge_info k on k.id = f.knowledge_id and k.tenant_id = f.tenant_id
left join aihr_knowledge_attach a
@@ -975,22 +1171,30 @@ public class AihrKnowledgeQueryService {
%s
""".formatted(placeholders(allowedSpaceIds.size()), placeholders(fragmentIds.size()),
formalSourceClause),
(rs, rowNum) -> new Citation(
rs.getString("space_code"), "DOCUMENT", rs.getString("doc_id"), rs.getString("title"),
rs.getString("content"), rs.getLong("fragment_id"), "ENTERPRISE", null, null, null,
null, null, null, rs.getString("source_authority"),
AihrRagQueryPlanner.sourceKind(rs.getString("source_authority"), rs.getString("source_type"),
rs.getString("source_name")), null, rs.getString("source_version"),
rs.getDate("effective_from") == null ? null : rs.getDate("effective_from").toString(),
rs.getString("applicability")
(rs, rowNum) -> new GovernedCitation(
new Citation(
rs.getString("space_code"), "DOCUMENT", rs.getString("doc_id"), rs.getString("title"),
rs.getString("content"), rs.getLong("fragment_id"), "ENTERPRISE", null, null, null,
null, null, null, rs.getString("source_authority"),
AihrRagQueryPlanner.sourceKind(rs.getString("source_authority"), rs.getString("source_type"),
rs.getString("source_name")), null, rs.getString("source_version"),
rs.getDate("effective_from") == null ? null : rs.getDate("effective_from").toString(),
rs.getString("applicability")
),
rs.getString("applicable_role")
), args.toArray());
Map<Long, Citation> byId = new LinkedHashMap<>();
rows.forEach(row -> byId.put(row.fragmentId(), row));
Map<Long, GovernedCitation> byId = new LinkedHashMap<>();
rows.forEach(row -> byId.put(row.citation().fragmentId(), row));
// 授权/生效门保持不变;formalPolicyOnly 不再按来源级别过滤,改为标记 tier:
// FORMAL 级证据仍需通过相关性判定,REFERENCE 级证据保留并降级为参考经验。
// 岗位门:EXPERIENCE/CASE 类证据额外要求 applicable_role 为空(通用)或包含当前
// 员工岗位(服务端解析);解析不到岗位时仅空适用范围可通过。其他来源行为不变。
return fragmentIds.stream()
.map(byId::get)
.filter(java.util.Objects::nonNull)
.filter(row -> !POSITION_GATED_SOURCE_KINDS.contains(row.citation().sourceKind())
|| AihrPositionGate.matches(row.applicableRole(), positions))
.map(GovernedCitation::citation)
.map(row -> new Citation(
row.spaceCode(), row.sourceType(), row.docId(), row.title(),
row.snippet(), row.fragmentId(), row.domain(), row.status(), row.occurredAt(),
@@ -1118,6 +1322,9 @@ public class AihrKnowledgeQueryService {
}
private static final String PROJECT_SERVICE = "PROJECT_SERVICE";
private static final String CASE_EXP_SOURCE_TYPE = "CASE_EXP";
private static final String COMMUNITY_SOURCE_TYPE = "COMMUNITY";
private static final Set<String> POSITION_GATED_SOURCE_KINDS = Set.of("EXPERIENCE", "CASE");
private static final String BROADCAST_PROMPT_VERSION = "broadcast-context-v1";
private static final int BROADCAST_MODEL_ANSWER_MAX = 1200;
private static final int BROADCAST_ECHO_FRAGMENT_MIN = 24;
@@ -1134,6 +1341,9 @@ public class AihrKnowledgeQueryService {
private record AttachmentResource(Long attachmentId, String title) {
}
private record GovernedCitation(Citation citation, String applicableRole) {
}
private record CitationLocation(Long attachmentId, String sourceKind,
org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.LocatorSummary summary) {
}
@@ -238,6 +238,10 @@ public class AihrCaseService {
}
public List<RecordResponse> records(Integer limit, List<String> projectScopes) {
return records(limit, projectScopes, null);
}
public List<RecordResponse> records(Integer limit, List<String> projectScopes, List<String> positions) {
ensureCaseTable();
if (projectScopes != null && projectScopes.isEmpty()) {
return List.of();
@@ -249,9 +253,14 @@ public class AihrCaseService {
projectWhere = " AND project_ext_org_id IN (" + placeholders(projectScopes.size()) + ")";
args.addAll(projectScopes);
}
String publishedWhere = projectScopes == null ? "" : " AND status = '已入库' AND knowledge_status = 'PUBLISHED'"
+ " AND (effective_from IS NULL OR effective_from <= CURRENT_DATE())"
+ " AND (effective_to IS NULL OR effective_to >= CURRENT_DATE())";
String publishedWhere = "";
if (projectScopes != null) {
publishedWhere = " AND status = '已入库' AND knowledge_status = 'PUBLISHED'"
+ " AND (effective_from IS NULL OR effective_from <= CURRENT_DATE())"
+ " AND (effective_to IS NULL OR effective_to >= CURRENT_DATE())"
+ " AND " + AihrPositionGate.applicableScopeSql("applicable_positions", positions);
args.addAll(AihrPositionGate.normalizedPositions(positions));
}
args.add(safeLimit);
String sql = """
SELECT case_id, title, type, status, knowledge_status, source_type,
@@ -279,6 +288,10 @@ public class AihrCaseService {
}
public DetailResponse detail(String caseId, List<String> projectScopes) {
return detail(caseId, projectScopes, null);
}
public DetailResponse detail(String caseId, List<String> projectScopes, List<String> positions) {
String normalizedCaseId = clean(caseId);
if (normalizedCaseId.isBlank() || (projectScopes != null && projectScopes.isEmpty())) {
return null;
@@ -291,9 +304,14 @@ public class AihrCaseService {
projectWhere = " AND project_ext_org_id IN (" + placeholders(projectScopes.size()) + ")";
args.addAll(projectScopes);
}
String publishedWhere = projectScopes == null ? "" : " AND status = '已入库' AND knowledge_status = 'PUBLISHED'"
+ " AND (effective_from IS NULL OR effective_from <= CURRENT_DATE())"
+ " AND (effective_to IS NULL OR effective_to >= CURRENT_DATE())";
String publishedWhere = "";
if (projectScopes != null) {
publishedWhere = " AND status = '已入库' AND knowledge_status = 'PUBLISHED'"
+ " AND (effective_from IS NULL OR effective_from <= CURRENT_DATE())"
+ " AND (effective_to IS NULL OR effective_to >= CURRENT_DATE())"
+ " AND " + AihrPositionGate.applicableScopeSql("applicable_positions", positions);
args.addAll(AihrPositionGate.normalizedPositions(positions));
}
String sql = """
SELECT case_id, file_name, title, type, status, knowledge_status, source_type,
applicable_positions, content_version, effective_from, effective_to,
@@ -337,6 +355,10 @@ public class AihrCaseService {
* The controller streams the object through the authenticated API instead of exposing the raw OSS URL.
*/
public Long authorizedMediaOssId(String caseId, List<String> projectScopes) {
return authorizedMediaOssId(caseId, projectScopes, null);
}
public Long authorizedMediaOssId(String caseId, List<String> projectScopes, List<String> positions) {
String normalizedCaseId = clean(caseId);
if (normalizedCaseId.isBlank() || (projectScopes != null && projectScopes.isEmpty())) {
return null;
@@ -349,9 +371,14 @@ public class AihrCaseService {
projectWhere = " AND project_ext_org_id IN (" + placeholders(projectScopes.size()) + ")";
args.addAll(projectScopes);
}
String publishedWhere = projectScopes == null ? "" : " AND status = '已入库' AND knowledge_status = 'PUBLISHED'"
+ " AND (effective_from IS NULL OR effective_from <= CURRENT_DATE())"
+ " AND (effective_to IS NULL OR effective_to >= CURRENT_DATE())";
String publishedWhere = "";
if (projectScopes != null) {
publishedWhere = " AND status = '已入库' AND knowledge_status = 'PUBLISHED'"
+ " AND (effective_from IS NULL OR effective_from <= CURRENT_DATE())"
+ " AND (effective_to IS NULL OR effective_to >= CURRENT_DATE())"
+ " AND " + AihrPositionGate.applicableScopeSql("applicable_positions", positions);
args.addAll(AihrPositionGate.normalizedPositions(positions));
}
String sql = """
SELECT media_oss_id
FROM aihr_case_record
@@ -393,6 +420,37 @@ public class AihrCaseService {
}
}
/**
* Active positions of an APP employee, resolved by exact mobile phone with the same
* identity discipline as {@link #projectScopesForAppUser}. An empty result is fail-closed
* at the position gate (only universal experience remains visible).
*/
public List<String> positionsForAppUser(String mobilePhone) {
String phone = clean(mobilePhone);
if (phone.isBlank()) {
return List.of();
}
try {
return jdbcTemplate.query("""
SELECT DISTINCT position_name
FROM aihr_org_snapshot
WHERE tenant_id = ?
AND employment_status = 'active'
AND person_phone = ?
AND position_name IS NOT NULL
AND position_name <> ''
ORDER BY position_name
""", (rs, rowNum) -> rs.getString("position_name"), tenantId(), phone).stream()
.map(AihrCaseService::clean)
.filter(value -> !value.isBlank())
.distinct()
.toList();
} catch (DataAccessException e) {
log.warn("resolve app user positions failed(处理错误已隐藏)");
return List.of();
}
}
public boolean isCaseContributor(String mobilePhone) {
String phone = clean(mobilePhone);
if (phone.isBlank()) {
@@ -497,9 +555,11 @@ public class AihrCaseService {
"media_oss_id", "media_url", "learning_points", "supervisor_comment",
"synthetic_content", "generator_type", "model_name", "prompt_version",
"grounding_oss_id", "ai_summary_hash", "reviewer_id", "reviewed_time",
"knowledge_status", "source_type", "applicable_positions", "content_version",
"knowledge_status", "source_type", "source_ref", "applicable_positions", "content_version",
"source_authorized", "deidentified", "effective_from", "effective_to",
"release_owner", "release_note", "released_by", "released_time", "retire_reason");
AihrSchemaMigrationGuard.requireIndexDefinition(jdbcTemplate, "aihr_case_record",
"uk_aihr_case_record_source", true, "tenant_id", "source_type", "source_ref");
tableReady = true;
return;
}
@@ -522,6 +582,7 @@ public class AihrCaseService {
`owner` varchar(80) DEFAULT '培训组' COMMENT '负责人',
`knowledge_status` varchar(20) NOT NULL DEFAULT 'CANDIDATE' COMMENT 'CANDIDATE/PUBLISHED/OFFLINE',
`source_type` varchar(40) DEFAULT NULL COMMENT '经验来源类型',
`source_ref` varchar(80) DEFAULT NULL COMMENT '经验来源引用(如问题榜回答ID)',
`applicable_positions` varchar(500) DEFAULT NULL COMMENT '适用岗位',
`content_version` varchar(50) DEFAULT NULL COMMENT '正式经验版本',
`source_authorized` tinyint(1) NOT NULL DEFAULT 0 COMMENT '来源是否已授权',
@@ -545,6 +606,7 @@ public class AihrCaseService {
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_case_record_case` (`tenant_id`, `case_id`),
UNIQUE KEY `uk_aihr_case_record_source` (`tenant_id`, `source_type`, `source_ref`),
KEY `idx_aihr_case_record_status` (`tenant_id`, `status`, `update_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 案例沉淀记录';
""");
@@ -562,6 +624,9 @@ public class AihrCaseService {
addCaseColumn("reviewed_time", "ALTER TABLE aihr_case_record ADD COLUMN reviewed_time datetime DEFAULT NULL COMMENT '人工审核时间'");
addCaseColumn("knowledge_status", "ALTER TABLE aihr_case_record ADD COLUMN knowledge_status varchar(20) NOT NULL DEFAULT 'CANDIDATE' COMMENT 'CANDIDATE/PUBLISHED/OFFLINE'");
addCaseColumn("source_type", "ALTER TABLE aihr_case_record ADD COLUMN source_type varchar(40) DEFAULT NULL COMMENT '经验来源类型'");
addCaseColumn("source_ref", "ALTER TABLE aihr_case_record ADD COLUMN source_ref varchar(80) DEFAULT NULL COMMENT '经验来源引用(如问题榜回答ID)'");
addCaseIndex("uk_aihr_case_record_source",
"ALTER TABLE aihr_case_record ADD UNIQUE KEY uk_aihr_case_record_source (tenant_id, source_type, source_ref)");
addCaseColumn("applicable_positions", "ALTER TABLE aihr_case_record ADD COLUMN applicable_positions varchar(500) DEFAULT NULL COMMENT '适用岗位'");
addCaseColumn("content_version", "ALTER TABLE aihr_case_record ADD COLUMN content_version varchar(50) DEFAULT NULL COMMENT '正式经验版本'");
addCaseColumn("source_authorized", "ALTER TABLE aihr_case_record ADD COLUMN source_authorized tinyint(1) NOT NULL DEFAULT 0 COMMENT '来源是否已授权'");
@@ -754,6 +819,20 @@ public class AihrCaseService {
}
}
private void addCaseIndex(String index, String sql) {
try {
Integer count = jdbcTemplate.queryForObject("""
select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'aihr_case_record' and index_name = ?
""", Integer.class, index);
if (count == null || count == 0) {
jdbcTemplate.execute(sql);
}
} catch (RuntimeException e) {
log.debug("case table index already exists or cannot be created: {}", index);
}
}
private static List<TagResponse> tagsFromText(String text) {
String source = clean(text);
List<TagResponse> tags = new ArrayList<>();
@@ -0,0 +1,66 @@
package org.dromara.aihr.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.common.core.enums.UserType;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Objects;
/**
* Server-side resolution of the current employee's active positions from the organization
* snapshot. Clients never submit a position: APP employees are looked up by their authenticated
* organization identity ({@code ext_party_id}); anything that cannot be resolved (including
* management-side previews without an organization identity) yields an empty result, which the
* position gate treats as "only universal (empty-scope) experience applies".
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class AihrEmployeePositionResolver {
private final JdbcTemplate jdbcTemplate;
public List<String> resolvePositions(AihrKnowledgePrincipal principal) {
if (principal == null || !UserType.APP_USER.getUserType().equals(principal.userType())
|| !hasText(principal.extPartyId()) || jdbcTemplate == null) {
return List.of();
}
try {
return jdbcTemplate.query("""
SELECT DISTINCT position_name
FROM aihr_org_snapshot
WHERE tenant_id = ?
AND employment_status = 'active'
AND ext_party_id = ?
AND position_name IS NOT NULL AND position_name <> ''
ORDER BY position_name
""", (rs, rowNum) -> rs.getString("position_name"),
principal.tenantId(), principal.extPartyId()).stream()
.filter(Objects::nonNull)
.map(String::trim)
.filter(value -> !value.isEmpty())
.distinct()
.toList();
} catch (DataAccessException ex) {
// Position resolution must never break the main chain; an unresolved position is
// fail-closed at the gate (only universal experience remains visible).
log.warn("resolve employee positions failed, falling back to universal scope only");
return List.of();
}
}
/** The first active position in deterministic order, or null when none can be resolved. */
public String resolvePosition(AihrKnowledgePrincipal principal) {
List<String> positions = resolvePositions(principal);
return positions.isEmpty() ? null : positions.get(0);
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
}
@@ -0,0 +1,83 @@
package org.dromara.aihr.service;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
/**
* Position gate for experience/case-type evidence. An empty applicable scope is universal and
* always passes; a non-empty scope requires the current employee position (server-resolved,
* never client-supplied) to appear in the scope. When no position can be resolved the gate is
* fail-closed: only universal (empty-scope) entries pass.
*
* <p>The applicable scope is stored as free text separated by ASCII/Chinese commas, Chinese
* enumeration marks or semicolons (for example "生活顾问,客服管家" or "生活顾问、保安"). Both
* sides are normalized the same way before comparison: separators become commas and all
* whitespace is removed.</p>
*/
public final class AihrPositionGate {
private static final String SCOPE_SPLIT = "[,,、;;/]";
private AihrPositionGate() {
}
/** Trims entries, removes whitespace, drops blanks and dedupes, keeping order. */
public static List<String> normalizedPositions(List<String> positions) {
if (positions == null || positions.isEmpty()) {
return List.of();
}
LinkedHashSet<String> normalized = new LinkedHashSet<>();
for (String position : positions) {
String value = normalizeToken(position);
if (!value.isEmpty()) {
normalized.add(value);
}
}
return List.copyOf(normalized);
}
/**
* Java-side hard gate. Blank scope is universal; otherwise any normalized employee position
* must appear in the scope. Empty employee positions only match the universal scope.
*/
public static boolean matches(String applicableScope, List<String> positions) {
String scope = applicableScope == null ? "" : applicableScope.trim();
if (scope.isEmpty()) {
return true;
}
List<String> normalized = normalizedPositions(positions);
if (normalized.isEmpty()) {
return false;
}
for (String token : scope.split(SCOPE_SPLIT)) {
if (normalized.contains(normalizeToken(token))) {
return true;
}
}
return false;
}
/**
* SQL predicate equivalent of {@link #matches} for a stored scope column. Emits one {@code ?}
* placeholder per normalized position; bind {@link #normalizedPositions} in the same order.
*/
public static String applicableScopeSql(String column, List<String> positions) {
List<String> normalized = normalizedPositions(positions);
String universal = "(" + column + " IS NULL OR TRIM(" + column + ") = '')";
if (normalized.isEmpty()) {
return universal;
}
String normalizedColumn = "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(" + column + ","
+ " ',', ','), '、', ','), ';', ','), ';', ','), ' ', '')";
List<String> clauses = new ArrayList<>();
for (int index = 0; index < normalized.size(); index++) {
clauses.add("FIND_IN_SET(?, " + normalizedColumn + ") > 0");
}
return "(" + universal + " OR " + String.join(" OR ", clauses) + ")";
}
private static String normalizeToken(String value) {
return value == null ? "" : value.replaceAll("\\s+", "").trim();
}
}
@@ -57,6 +57,7 @@ class AihrAgentMediaTest {
null,
null,
null,
null,
null
);
}
@@ -19,6 +19,7 @@ import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.CurrentTa
import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryDraft;
import org.dromara.aihr.agent.shadow.AihrAgentShadowService;
import org.dromara.aihr.service.AihrEmployeePositionResolver;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@@ -39,6 +40,7 @@ import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@Tag("dev")
class AihrAgentOrchestratorTest {
@@ -341,6 +343,13 @@ class AihrAgentOrchestratorTest {
private static AihrAgentOrchestrator orchestrator(CapturingQueryService query,
AihrAgentActionService actionService,
AihrAgentShadowService shadowService) {
return orchestrator(query, actionService, shadowService, null);
}
private static AihrAgentOrchestrator orchestrator(CapturingQueryService query,
AihrAgentActionService actionService,
AihrAgentShadowService shadowService,
AihrEmployeePositionResolver positionResolver) {
return new AihrAgentOrchestrator(
new AihrAgentPlanner(null, new ObjectMapper()),
new AihrAgentPolicy(),
@@ -355,7 +364,8 @@ class AihrAgentOrchestratorTest {
actionService,
null,
null,
shadowService
shadowService,
positionResolver
);
}
@@ -374,6 +384,26 @@ class AihrAgentOrchestratorTest {
broadcastContext);
}
@Test
void knowledgeQuestionCarriesServerResolvedPositionIntoQuery() {
var query = new CapturingQueryService(response("按制度处理。", null, false));
var positionResolver = mock(AihrEmployeePositionResolver.class);
when(positionResolver.resolvePosition(any())).thenReturn("生活顾问");
orchestrator(query, null, null, positionResolver).handle(request("电梯困人怎么处理"));
assertEquals("生活顾问", query.lastRequest.position());
}
@Test
void knowledgeQuestionWithoutPositionResolutionKeepsNullPosition() {
var query = new CapturingQueryService(response("按制度处理。", null, false));
orchestrator(query).handle(request("电梯困人怎么处理"));
assertNull(query.lastRequest.position());
}
private static final class CapturingQueryService extends AihrKnowledgeQueryService {
private final QueryResponse response;
private QueryRequest lastRequest;
@@ -74,6 +74,7 @@ class AihrAgentWebResearchTest {
null,
web,
null,
null,
null
);
}
@@ -0,0 +1,225 @@
package org.dromara.aihr.community;
import org.dromara.aihr.community.AihrCommunityDto.BestAnswerMatch;
import org.dromara.aihr.community.AihrCommunityDto.CaseCandidateResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
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 java.sql.ResultSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
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.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@Tag("dev")
class AihrCommunityBestAnswerSourceTest {
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void searchBestAnswersOnlyReadsAcceptedAnswersOfVisibleQuestionsWithoutSubmitterIdentity() {
var jdbc = mock(JdbcTemplate.class);
var service = new AihrCommunityService(jdbc,
mock(AihrKnowledgePrincipalResolver.class), new AihrCommunityProperties(), mock(AihrIncentiveService.class));
var sqlCaptor = ArgumentCaptor.forClass(String.class);
var argsCaptor = ArgumentCaptor.forClass(Object[].class);
when(jdbc.query(sqlCaptor.capture(), any(RowMapper.class), argsCaptor.capture())).thenAnswer(invocation -> {
RowMapper<?> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("question_id")).thenReturn(7L);
when(rs.getLong("answer_id")).thenReturn(55L);
when(rs.getString("title")).thenReturn("电梯困人怎么安抚住户");
when(rs.getString("content")).thenReturn("先安抚情绪,再按流程报修并留痕。");
return List.of(mapper.mapRow(rs, 0));
});
List<BestAnswerMatch> matches = service.searchBestAnswers("000000", "电梯困人怎么处理", 3);
assertEquals(1, matches.size());
BestAnswerMatch match = matches.get(0);
assertEquals(7L, match.questionId());
assertEquals(55L, match.answerId());
assertEquals("电梯困人怎么安抚住户", match.questionTitle());
String sql = sqlCaptor.getValue();
assertTrue(sql.contains("a.answer_status = 'PUBLISHED'"));
assertTrue(sql.contains("a.is_best = 1"));
assertTrue(sql.contains("q.question_status IN ('PUBLISHED', 'CLOSED')"));
assertTrue(sql.contains("a.tenant_id = ?"));
assertFalse(sql.contains("author"), "best-answer retrieval must never select submitter identity columns");
assertEquals("000000", argsCaptor.getValue()[0]);
assertEquals(3, argsCaptor.getValue()[argsCaptor.getValue().length - 1]);
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void searchBestAnswersSkipsBlankQueryAndOtherTenantsStayOutOfTheQuery() {
var jdbc = mock(JdbcTemplate.class);
var service = new AihrCommunityService(jdbc,
mock(AihrKnowledgePrincipalResolver.class), new AihrCommunityProperties(), mock(AihrIncentiveService.class));
assertTrue(service.searchBestAnswers("000000", " ", 3).isEmpty());
assertTrue(service.searchBestAnswers("", "电梯困人", 3).isEmpty());
verify(jdbc, never()).query(anyString(), any(RowMapper.class), any(Object[].class));
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void toCaseCandidateCreatesPendingCandidateWithoutAuthorizationFlags() {
var jdbc = mock(JdbcTemplate.class);
stubSchemaBootstrap(jdbc);
var principal = new AihrKnowledgePrincipal("000000", 9L, "sys_user", "",
Set.of("admin"), Set.of(), "pc");
var resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal);
var service = new AihrCommunityService(jdbc, resolver, new AihrCommunityProperties(),
mock(AihrIncentiveService.class));
var selectSql = ArgumentCaptor.forClass(String.class);
when(jdbc.query(selectSql.capture(), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> {
String sql = invocation.getArgument(0);
if (sql.contains("from aihr_case_record")) {
return List.of();
}
RowMapper<?> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(55L);
when(rs.getLong("question_id")).thenReturn(7L);
when(rs.getString("content")).thenReturn("先安抚情绪,再按流程报修。");
when(rs.getString("answer_status")).thenReturn("PUBLISHED");
when(rs.getBoolean("is_best")).thenReturn(true);
when(rs.getString("title")).thenReturn("电梯困人怎么安抚住户");
when(rs.getString("category")).thenReturn("工程维修");
return List.of(mapper.mapRow(rs, 0));
});
var updateSql = ArgumentCaptor.forClass(String.class);
var updateArgs = ArgumentCaptor.forClass(Object[].class);
when(jdbc.update(updateSql.capture(), updateArgs.capture())).thenReturn(1);
CaseCandidateResponse response = service.toCaseCandidate(55L);
assertTrue(response.created());
assertTrue(response.caseId().startsWith("case-"));
String insert = updateSql.getValue();
assertTrue(insert.contains("insert into aihr_case_record"));
assertTrue(insert.contains("'待审核', 'CANDIDATE', 'COMMUNITY_QA'"));
assertTrue(insert.contains("source_authorized, deidentified"));
assertTrue(insert.contains(", 0, 0,"), "candidate must keep source_authorized=0 and deidentified=0");
Object[] args = updateArgs.getValue();
assertEquals("000000", args[0]);
assertEquals("community-answer:55", args[2]);
assertEquals("55", args[4]);
assertEquals("保修", args[args.length - 1], "category 工程维修 maps to the 保修 position");
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void toCaseCandidateIsIdempotentPerAnswer() {
var jdbc = mock(JdbcTemplate.class);
stubSchemaBootstrap(jdbc);
var principal = new AihrKnowledgePrincipal("000000", 9L, "sys_user", "",
Set.of("admin"), Set.of(), "pc");
var resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal);
var service = new AihrCommunityService(jdbc, resolver, new AihrCommunityProperties(),
mock(AihrIncentiveService.class));
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> {
String sql = invocation.getArgument(0);
if (sql.contains("from aihr_case_record")) {
RowMapper<?> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getString("case_id")).thenReturn("case-existing");
return List.of(mapper.mapRow(rs, 0));
}
RowMapper<?> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(55L);
when(rs.getLong("question_id")).thenReturn(7L);
when(rs.getString("answer_status")).thenReturn("PUBLISHED");
when(rs.getBoolean("is_best")).thenReturn(true);
return List.of(mapper.mapRow(rs, 0));
});
CaseCandidateResponse response = service.toCaseCandidate(55L);
assertFalse(response.created());
assertEquals("case-existing", response.caseId());
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
@Test
void toCaseCandidateRequiresModeratorRole() {
var jdbc = mock(JdbcTemplate.class);
var principal = new AihrKnowledgePrincipal("000000", 5L, "app_user", "employee-5",
Set.of("employee"), Set.of("P1"), "app");
var resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal);
var service = new AihrCommunityService(jdbc, resolver, new AihrCommunityProperties(),
mock(AihrIncentiveService.class));
ServiceException error = assertThrows(ServiceException.class, () -> service.toCaseCandidate(55L));
assertEquals(403, error.getCode());
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void toCaseCandidateRejectsNonBestOrMissingAnswers() {
var jdbc = mock(JdbcTemplate.class);
stubSchemaBootstrap(jdbc);
var principal = new AihrKnowledgePrincipal("000000", 9L, "sys_user", "",
Set.of("admin"), Set.of(), "pc");
var resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal);
var service = new AihrCommunityService(jdbc, resolver, new AihrCommunityProperties(),
mock(AihrIncentiveService.class));
AtomicInteger call = new AtomicInteger();
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> {
String sql = invocation.getArgument(0);
if (sql.contains("aihr_community_answer") && call.getAndIncrement() == 0) {
RowMapper<?> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getBoolean("is_best")).thenReturn(false);
when(rs.getString("answer_status")).thenReturn("PUBLISHED");
return List.of(mapper.mapRow(rs, 0));
}
return List.of();
});
assertThrows(IllegalArgumentException.class, () -> service.toCaseCandidate(55L));
assertThrows(IllegalArgumentException.class, () -> service.toCaseCandidate(99L));
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
@Test
void categoryPositionMappingFallsBackToUniversalWhenUndecidable() {
assertEquals("保安", AihrCommunityService.positionForCategory("秩序维护"));
assertEquals("保洁", AihrCommunityService.positionForCategory("环境绿化"));
assertEquals("保修", AihrCommunityService.positionForCategory("工程维修"));
assertEquals("客服", AihrCommunityService.positionForCategory("客服管家"));
assertEquals("生活顾问", AihrCommunityService.positionForCategory("生活顾问"));
assertEquals("", AihrCommunityService.positionForCategory("工作求助"));
assertEquals("", AihrCommunityService.positionForCategory(null));
}
@SuppressWarnings({"rawtypes", "unchecked"})
private static void stubSchemaBootstrap(JdbcTemplate jdbc) {
// runtimeSchemaBootstrap defaults to true in plain unit tests; schema guard reads stay lenient.
lenient().when(jdbc.queryForObject(anyString(), any(Class.class))).thenReturn(null);
}
}
@@ -0,0 +1,293 @@
package org.dromara.aihr.knowledge;
import org.dromara.aihr.community.AihrCommunityService;
import org.dromara.aihr.community.AihrCommunityDto.BestAnswerMatch;
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.Citation;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse;
import org.dromara.aihr.knowledge.service.AihrEvidenceGradingService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeAccessService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService;
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.memory.AihrMemoryService;
import org.dromara.aihr.service.AihrEmployeePositionResolver;
import org.dromara.aihr.service.AihrSopSeedService;
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 java.sql.ResultSet;
import java.util.ArrayList;
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@Tag("dev")
class AihrKnowledgeAuxiliarySourceTest {
@Test
void positionGateHardFiltersExperienceCitationsWithoutTouchingFormalSources() {
var fixture = fixture(List.of("生活顾问"));
fixture.citationAuthority = "EMPLOYEE_SUBMISSION";
fixture.citationApplicableRole = "保安";
stubSearch(fixture.sop(), new SnippetResponse("经验萃取", "保安岗位的经验", 99L));
QueryResponse mismatched = fixture.service().queryInternal(question("电梯困人怎么处理"));
assertTrue(mismatched.citations().isEmpty(), "其他岗位的经验类证据必须被硬过滤");
fixture.citationApplicableRole = "生活顾问";
QueryResponse matched = fixture.service().queryInternal(question("电梯困人怎么处理"));
assertEquals(1, matched.citations().size());
assertEquals("EXPERIENCE", matched.citations().get(0).sourceKind());
fixture.citationAuthority = "FORMAL_POLICY";
fixture.citationApplicableRole = "保安";
QueryResponse formal = fixture.service().queryInternal(question("电梯困人怎么处理"));
assertEquals(1, formal.citations().size(), "岗位门不影响正式制度来源");
assertEquals("FORMAL_POLICY", formal.citations().get(0).sourceKind());
var noPosition = fixture(List.of());
noPosition.citationAuthority = "EMPLOYEE_SUBMISSION";
noPosition.citationApplicableRole = "生活顾问";
stubSearch(noPosition.sop(), new SnippetResponse("经验萃取", "经验", 99L));
QueryResponse unresolved = noPosition.service().queryInternal(question("电梯困人怎么处理"));
assertTrue(unresolved.citations().isEmpty(), "岗位解析不到时仅空适用范围可通过");
noPosition.citationApplicableRole = null;
QueryResponse universal = noPosition.service().queryInternal(question("电梯困人怎么处理"));
assertEquals(1, universal.citations().size(), "空适用范围对任何岗位通用");
}
@Test
void publishedExperiencesMergeAsReferenceTierCitations() {
var fixture = fixture(List.of("生活顾问"));
fixture.experienceRow = true;
stubSearch(fixture.sop());
QueryResponse result = fixture.service().queryInternal(question("电梯困人怎么处理"));
assertFalse(result.noEvidence());
assertEquals(AihrEvidenceGradingService.LEVEL_GUIDANCE, result.evidenceLevel());
assertTrue(result.answer().contains(AihrEvidenceGradingService.GUIDANCE_MARKER));
assertEquals(1, result.citations().size());
Citation citation = result.citations().get(0);
assertEquals("CASE_EXP", citation.sourceType());
assertEquals("case:case-1", citation.docId());
assertNull(citation.fragmentId());
assertEquals("CASE_EXP", citation.domain());
assertEquals("CASE", citation.sourceKind());
assertEquals(AihrEvidenceGradingService.TIER_REFERENCE, citation.evidenceTier());
assertEquals("v1.0", citation.sourceVersion());
assertTrue(fixture.sqlOf("aihr_case_record").contains("knowledge_status = 'PUBLISHED'"));
assertTrue(fixture.sqlOf("aihr_case_record").contains("FIND_IN_SET(?"));
assertTrue(fixture.sqlOf("aihr_case_record").contains("LIMIT 3"));
var sourceTypes = ArgumentCaptor.forClass(List.class);
verify(fixture.audit()).recordFinal(anyString(), any(), any(), anyString(), any(),
sourceTypes.capture(), anyString(), anyLong(), any());
assertTrue(sourceTypes.getValue().contains("CASE_EXP"));
}
@Test
void communityBestAnswersMergeAsReferenceTierCitations() {
var community = mock(AihrCommunityService.class);
when(community.searchBestAnswers(anyString(), anyString(), eq(3))).thenReturn(List.of(
new BestAnswerMatch(7L, 55L, "电梯困人怎么安抚住户", "先安抚情绪,再按流程报修。")));
var fixture = fixture(List.of("生活顾问"), community);
stubSearch(fixture.sop());
QueryResponse result = fixture.service().queryInternal(question("电梯困人怎么处理"));
assertFalse(result.noEvidence());
assertEquals(AihrEvidenceGradingService.LEVEL_GUIDANCE, result.evidenceLevel());
assertEquals(1, result.citations().size());
Citation citation = result.citations().get(0);
assertEquals("COMMUNITY", citation.sourceType());
assertEquals("community-answer:55", citation.docId());
assertNull(citation.fragmentId());
assertEquals("COMMUNITY", citation.domain());
assertEquals(AihrEvidenceGradingService.TIER_REFERENCE, citation.evidenceTier());
verify(community).searchBestAnswers(eq("000000"), eq("电梯困人怎么处理"), eq(3));
}
@Test
void zeroAuxiliaryHitsKeepTheMainChainUntouched() {
var fixture = fixture(List.of("生活顾问"));
stubSearch(fixture.sop());
QueryResponse result = fixture.service().queryInternal(question("电梯困人怎么处理"));
assertTrue(result.noEvidence());
assertTrue(result.citations().isEmpty());
assertEquals(AihrEvidenceGradingService.LEVEL_ROUTE, result.evidenceLevel());
assertEquals(AihrEvidenceGradingService.GENERIC_ROUTE_ANSWER, result.answer());
}
@Test
void auxiliarySourceFailureNeverBreaksTheMainChain() {
var community = mock(AihrCommunityService.class);
when(community.searchBestAnswers(anyString(), anyString(), eq(3)))
.thenThrow(new IllegalStateException("community store unavailable"));
var fixture = fixture(List.of("生活顾问"), community);
stubSearch(fixture.sop());
QueryResponse result = fixture.service().queryInternal(question("电梯困人怎么处理"));
assertTrue(result.noEvidence());
assertEquals(AihrEvidenceGradingService.LEVEL_ROUTE, result.evidenceLevel());
}
@Test
void formalDocumentEvidenceKeepsItsAnswerWhenAuxiliaryReferencesAreAppended() {
var fixture = fixture(List.of("生活顾问"));
fixture.citationAuthority = "FORMAL_POLICY";
fixture.experienceRow = true;
stubSearch(fixture.sop(), new SnippetResponse("电梯制度", "按制度流程处置", 99L));
QueryResponse result = fixture.service().queryInternal(question("电梯困人怎么处理"));
assertFalse(result.noEvidence());
assertEquals(AihrEvidenceGradingService.LEVEL_FULL, result.evidenceLevel());
assertEquals("按制度提交报销单并保留发票。", result.answer());
assertEquals(2, result.citations().size());
assertEquals(AihrEvidenceGradingService.TIER_FORMAL, result.citations().get(0).evidenceTier());
assertEquals("CASE_EXP", result.citations().get(1).sourceType());
}
private static QueryRequest question(String text) {
return new QueryRequest(text, List.of(), "sop", null, "mobile_uni_agent", 5, null);
}
private static Fixture fixture(List<String> positions) {
return fixture(positions, null);
}
@SuppressWarnings({"rawtypes", "unchecked"})
private static Fixture fixture(List<String> positions, AihrCommunityService community) {
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 audit = mock(AihrKnowledgeQueryAuditService.class);
var memory = mock(AihrMemoryService.class);
var positionResolver = mock(AihrEmployeePositionResolver.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(positionResolver.resolvePositions(principal)).thenReturn(positions);
var fixture = new Fixture(null, sop, jdbc, audit, new ArrayList<>());
lenient().when(jdbc.queryForList(anyString(), eq(String.class), any(Object[].class)))
.thenReturn(List.of("sop"));
lenient().when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class)))
.thenAnswer(invocation -> {
String sql = invocation.getArgument(0);
fixture.queriedSql().add(sql);
RowMapper<?> mapper = invocation.getArgument(1);
if (sql.contains("aihr_knowledge_fragment") && fixture.citationAuthority != null) {
return List.of(mapper.mapRow(fixture.citationResultSet(), 0));
}
if (sql.contains("aihr_case_record") && fixture.experienceRow) {
return List.of(mapper.mapRow(fixture.experienceResultSet(), 0));
}
return List.of();
});
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop, audit, jdbc,
mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class),
memory, null, null, null, null, positionResolver, community);
fixture.service = service;
return fixture;
}
private static void stubSearch(AihrSopSeedService sop, SnippetResponse... snippets) {
when(sop.searchAuthorized(any(), any(), any())).thenAnswer(invocation ->
new SearchResponse("电梯困人怎么处理", "sop", "按制度提交报销单并保留发票。", "", List.of(),
List.of(snippets), List.of(), List.of(), List.of(), List.of(), "test-v1", null));
}
private static final class Fixture {
private AihrKnowledgeQueryService service;
private final AihrSopSeedService sop;
private final JdbcTemplate jdbc;
private final AihrKnowledgeQueryAuditService audit;
private final List<String> queriedSql;
private String citationAuthority;
private String citationApplicableRole;
private boolean experienceRow;
private Fixture(AihrKnowledgeQueryService service, AihrSopSeedService sop, JdbcTemplate jdbc,
AihrKnowledgeQueryAuditService audit, List<String> queriedSql) {
this.service = service;
this.sop = sop;
this.jdbc = jdbc;
this.audit = audit;
this.queriedSql = queriedSql;
}
private AihrKnowledgeQueryService service() {
return service;
}
private AihrSopSeedService sop() {
return sop;
}
private AihrKnowledgeQueryAuditService audit() {
return audit;
}
private List<String> queriedSql() {
return queriedSql;
}
private String sqlOf(String tableMarker) {
return queriedSql.stream().filter(sql -> sql.contains(tableMarker)).findFirst().orElse("");
}
private ResultSet citationResultSet() throws java.sql.SQLException {
ResultSet rs = mock(ResultSet.class);
lenient().when(rs.getString("space_code")).thenReturn("sop");
lenient().when(rs.getString("doc_id")).thenReturn("doc-1");
lenient().when(rs.getString("title")).thenReturn("电梯处置依据");
lenient().when(rs.getString("content")).thenReturn("按制度流程处置");
lenient().when(rs.getLong("fragment_id")).thenReturn(99L);
lenient().when(rs.getString("source_authority")).thenReturn(citationAuthority);
lenient().when(rs.getString("applicable_role")).thenReturn(citationApplicableRole);
return rs;
}
private ResultSet experienceResultSet() throws java.sql.SQLException {
ResultSet rs = mock(ResultSet.class);
lenient().when(rs.getString("case_id")).thenReturn("case-1");
lenient().when(rs.getString("title")).thenReturn("电梯困人安抚经验");
lenient().when(rs.getString("summary")).thenReturn("先安抚情绪,再按流程报修。");
lenient().when(rs.getString("content_version")).thenReturn("v1.0");
return rs;
}
}
}
@@ -283,6 +283,10 @@ class AihrKnowledgeQueryServiceTest {
when(jdbc.queryForList(any(String.class), any(Class.class), any(Object[].class)))
.thenReturn(List.of("sop"));
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> {
String sql = invocation.getArgument(0);
if (!sql.contains("aihr_knowledge_fragment")) {
return List.of();
}
RowMapper<?> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getString("space_code")).thenReturn("sop");
@@ -648,6 +652,10 @@ class AihrKnowledgeQueryServiceTest {
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);
if (!sql.contains("aihr_knowledge_fragment")) {
return List.of();
}
RowMapper<?> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getString("space_code")).thenReturn("sop");
@@ -311,6 +311,64 @@ class AihrCaseServiceTest {
assertEquals("培训组", AihrCaseService.caseOwner(" "));
}
@Test
@Tag("dev")
void appCaseRecallAppliesPositionGateWithBoundPositions() {
PositionGateCaseJdbcTemplate jdbcTemplate = new PositionGateCaseJdbcTemplate();
AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate, null);
service.records(10, List.of("P1"), List.of("生活顾问"));
assertTrue(jdbcTemplate.lastSql.contains("FIND_IN_SET(?"));
assertTrue(jdbcTemplate.lastSql.contains("applicable_positions IS NULL"));
assertEquals("生活顾问", jdbcTemplate.lastArgs[2]);
service.detail("case-1", List.of("P1"), List.of("保安"));
assertTrue(jdbcTemplate.lastSql.contains("FIND_IN_SET(?"));
assertEquals("保安", jdbcTemplate.lastArgs[3]);
service.authorizedMediaOssId("case-1", List.of("P1"), List.of("保安"));
assertTrue(jdbcTemplate.lastSql.contains("SELECT media_oss_id"));
assertTrue(jdbcTemplate.lastSql.contains("FIND_IN_SET(?"));
}
@Test
@Tag("dev")
void appCaseRecallWithoutPositionOnlyMatchesUniversalScope() {
PositionGateCaseJdbcTemplate jdbcTemplate = new PositionGateCaseJdbcTemplate();
AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate, null);
service.records(10, List.of("P1"));
assertTrue(jdbcTemplate.lastSql.contains("(applicable_positions IS NULL OR TRIM(applicable_positions) = '')"));
assertFalse(jdbcTemplate.lastSql.contains("FIND_IN_SET"));
}
@Test
@Tag("dev")
void adminCaseRecallSkipsPublishedAndPositionGates() {
PositionGateCaseJdbcTemplate jdbcTemplate = new PositionGateCaseJdbcTemplate();
AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate, null);
service.records(10, null);
assertFalse(jdbcTemplate.lastSql.contains("knowledge_status = 'PUBLISHED'"));
assertFalse(jdbcTemplate.lastSql.contains("FIND_IN_SET"));
assertFalse(jdbcTemplate.lastSql.contains("(applicable_positions IS NULL"));
}
private static final class PositionGateCaseJdbcTemplate extends CaseJdbcTemplate {
private String lastSql = "";
private Object[] lastArgs = new Object[0];
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
this.lastSql = sql;
this.lastArgs = args;
return List.of();
}
}
private static class CaseJdbcTemplate extends JdbcTemplate {
private Object[] updateArgs = new Object[0];
private String lastUpdateSql = "";
@@ -0,0 +1,79 @@
package org.dromara.aihr.service;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
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.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@Tag("dev")
class AihrEmployeePositionResolverTest {
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void appEmployeePositionsComeFromActiveOrgSnapshotByExtPartyId() {
var jdbc = mock(JdbcTemplate.class);
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class)))
.thenReturn(List.of("生活顾问", "客服"));
var resolver = new AihrEmployeePositionResolver(jdbc);
var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
Set.of("employee"), Set.of("P1"), "app");
assertEquals(List.of("生活顾问", "客服"), resolver.resolvePositions(principal));
assertEquals("生活顾问", resolver.resolvePosition(principal));
var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class);
var argsCaptor = org.mockito.ArgumentCaptor.forClass(Object[].class);
verify(jdbc, org.mockito.Mockito.times(2))
.query(sqlCaptor.capture(), any(RowMapper.class), argsCaptor.capture());
assertTrue(sqlCaptor.getValue().contains("aihr_org_snapshot"));
assertTrue(sqlCaptor.getValue().contains("employment_status = 'active'"));
assertTrue(sqlCaptor.getValue().contains("ext_party_id = ?"));
assertEquals(List.of("000000", "employee-7"), List.of(argsCaptor.getValue()));
}
@Test
void managementSideAndMissingIdentityResolveToNoPosition() {
var jdbc = mock(JdbcTemplate.class);
var resolver = new AihrEmployeePositionResolver(jdbc);
var sysUser = new AihrKnowledgePrincipal("000000", 1L, "sys_user", "",
Set.of("admin"), Set.of(), "pc");
var noIdentity = new AihrKnowledgePrincipal("000000", 2L, "app_user", "",
Set.of("employee"), Set.of(), "app");
assertTrue(resolver.resolvePositions(sysUser).isEmpty());
assertTrue(resolver.resolvePositions(noIdentity).isEmpty());
assertNull(resolver.resolvePosition(sysUser));
assertTrue(resolver.resolvePositions(null).isEmpty());
verify(jdbc, never()).query(anyString(), any(RowMapper.class), any(Object[].class));
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void snapshotFailureFailsClosedToUniversalScopeOnly() {
var jdbc = mock(JdbcTemplate.class);
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class)))
.thenThrow(new DataAccessException("boom") {
});
var resolver = new AihrEmployeePositionResolver(jdbc);
var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
Set.of("employee"), Set.of("P1"), "app");
assertTrue(resolver.resolvePositions(principal).isEmpty());
assertNull(resolver.resolvePosition(principal));
}
}
@@ -0,0 +1,61 @@
package org.dromara.aihr.service;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("dev")
class AihrPositionGateTest {
@Test
void emptyScopeIsUniversalForAnyPosition() {
assertTrue(AihrPositionGate.matches(null, List.of("生活顾问")));
assertTrue(AihrPositionGate.matches("", List.of("生活顾问")));
assertTrue(AihrPositionGate.matches(" ", List.of()));
}
@Test
void scopedExperienceRequiresTheEmployeePosition() {
assertTrue(AihrPositionGate.matches("生活顾问", List.of("生活顾问")));
assertTrue(AihrPositionGate.matches("生活顾问,客服", List.of("客服")));
assertTrue(AihrPositionGate.matches("生活顾问、保安", List.of("保安")));
assertTrue(AihrPositionGate.matches("生活顾问,保安", List.of("生活顾问")));
assertFalse(AihrPositionGate.matches("保安", List.of("生活顾问")));
assertFalse(AihrPositionGate.matches("保安主管", List.of("保安")));
}
@Test
void unresolvedPositionOnlyMatchesUniversalScope() {
assertTrue(AihrPositionGate.matches(null, List.of()));
assertTrue(AihrPositionGate.matches("", null));
assertFalse(AihrPositionGate.matches("生活顾问", List.of()));
assertFalse(AihrPositionGate.matches("生活顾问", null));
}
@Test
void whitespaceAndSeparatorVariantsNormalizeTheSameWay() {
assertTrue(AihrPositionGate.matches("生活顾问 , 客服", List.of(" 客服 ")));
assertTrue(AihrPositionGate.matches("生活顾问;保安", List.of("保安")));
assertEquals(List.of("生活顾问", "客服"), AihrPositionGate.normalizedPositions(
List.of(" 生活顾问 ", "客服", "客服", " ")));
}
@Test
void sqlPredicateMirrorsTheJavaGate() {
String universalOnly = AihrPositionGate.applicableScopeSql("applicable_positions", List.of());
assertTrue(universalOnly.contains("applicable_positions IS NULL"));
assertTrue(universalOnly.contains("TRIM(applicable_positions) = ''"));
assertFalse(universalOnly.contains("FIND_IN_SET"));
String gated = AihrPositionGate.applicableScopeSql("applicable_positions", List.of("生活顾问", "客服"));
assertTrue(gated.contains("applicable_positions IS NULL"));
assertEquals(2, gated.split("FIND_IN_SET\\(\\?", -1).length - 1);
assertTrue(gated.contains("REPLACE("));
assertFalse(gated.contains("生活顾问"), "positions must be bound parameters, never inlined");
}
}
@@ -0,0 +1,74 @@
-- Community best-answer to experience-candidate traceability and auxiliary retrieval indexes.
-- Additive and idempotent on MySQL 8. The new source_ref column keeps the originating
-- community answer id so the moderator-triggered conversion stays idempotent; the unique key
-- only protects (tenant, source_type, source_ref) triples and allows multiple NULL refs.
-- No existing row is approved, published or modified by this migration.
SET @aihr_case_source_ref_table_exists := (
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = 'aihr_case_record'
);
SET @aihr_case_source_ref_column_exists := (
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'aihr_case_record'
AND column_name = 'source_ref'
);
SET @aihr_case_source_ref_ddl := IF(
@aihr_case_source_ref_table_exists = 1 AND @aihr_case_source_ref_column_exists = 0,
'ALTER TABLE `aihr_case_record` ADD COLUMN `source_ref` varchar(80) DEFAULT NULL COMMENT ''经验来源引用(如问题榜回答ID)'' AFTER `source_type`',
'SELECT 1'
);
PREPARE aihr_case_source_ref_stmt FROM @aihr_case_source_ref_ddl;
EXECUTE aihr_case_source_ref_stmt;
DEALLOCATE PREPARE aihr_case_source_ref_stmt;
SET @aihr_case_source_ref_index_exists := (
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'aihr_case_record'
AND index_name = 'uk_aihr_case_record_source'
);
SET @aihr_case_source_ref_ddl := IF(
@aihr_case_source_ref_table_exists = 1 AND @aihr_case_source_ref_index_exists = 0,
'ALTER TABLE `aihr_case_record` ADD UNIQUE KEY `uk_aihr_case_record_source` (`tenant_id`, `source_type`, `source_ref`)',
'SELECT 1'
);
PREPARE aihr_case_source_ref_stmt FROM @aihr_case_source_ref_ddl;
EXECUTE aihr_case_source_ref_stmt;
DEALLOCATE PREPARE aihr_case_source_ref_stmt;
SET @aihr_community_best_table_exists := (
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = 'aihr_community_answer'
);
SET @aihr_community_best_index_exists := (
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'aihr_community_answer'
AND index_name = 'idx_aihr_community_answer_best'
);
SET @aihr_community_best_ddl := IF(
@aihr_community_best_table_exists = 1 AND @aihr_community_best_index_exists = 0,
'ALTER TABLE `aihr_community_answer` ADD KEY `idx_aihr_community_answer_best` (`tenant_id`, `is_best`, `answer_status`)',
'SELECT 1'
);
PREPARE aihr_community_best_stmt FROM @aihr_community_best_ddl;
EXECUTE aihr_community_best_stmt;
DEALLOCATE PREPARE aihr_community_best_stmt;
SET @aihr_case_source_ref_table_exists := NULL;
SET @aihr_case_source_ref_column_exists := NULL;
SET @aihr_case_source_ref_index_exists := NULL;
SET @aihr_case_source_ref_ddl := NULL;
SET @aihr_community_best_table_exists := NULL;
SET @aihr_community_best_index_exists := NULL;
SET @aihr_community_best_ddl := NULL;