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:
+12
-8
@@ -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");
|
||||
|
||||
+15
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+202
-1
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+19
-3
@@ -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())) {
|
||||
|
||||
+241
-31
@@ -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) {
|
||||
}
|
||||
|
||||
+89
-10
@@ -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<>();
|
||||
|
||||
+66
@@ -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();
|
||||
}
|
||||
}
|
||||
+83
@@ -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();
|
||||
}
|
||||
}
|
||||
+1
@@ -57,6 +57,7 @@ class AihrAgentMediaTest {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
+31
-1
@@ -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;
|
||||
|
||||
+1
@@ -74,6 +74,7 @@ class AihrAgentWebResearchTest {
|
||||
null,
|
||||
web,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
+225
@@ -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);
|
||||
}
|
||||
}
|
||||
+293
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -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");
|
||||
|
||||
+58
@@ -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 = "";
|
||||
|
||||
+79
@@ -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));
|
||||
}
|
||||
}
|
||||
+61
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user