fix(knowledge): gate policy answers by formal sources
This commit is contained in:
+12
-2
@@ -15,6 +15,7 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse;
|
||||
import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.CurrentTaskSummary;
|
||||
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
|
||||
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService;
|
||||
import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier;
|
||||
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService.MediaMode;
|
||||
import org.dromara.aihr.webai.AihrWebAiDto;
|
||||
import org.dromara.aihr.webai.AihrWebAiService;
|
||||
@@ -133,8 +134,9 @@ public class AihrAgentOrchestrator {
|
||||
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, "mobile_uni_agent", 5, null,
|
||||
request.question(), List.of(), "sop", null, source, 5, null,
|
||||
request.conversationId(), request.contextVersion(), request.projectCode(), request.broadcastMessageId()
|
||||
), file, mode);
|
||||
return fromResponse(plan, response, "MEDIA");
|
||||
@@ -145,8 +147,9 @@ public class AihrAgentOrchestrator {
|
||||
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, "mobile_uni_agent", 5, toolCode,
|
||||
plan.rewrittenRequest(), List.of(), "sop", null, source, 5, toolCode,
|
||||
request.conversationId(), request.contextVersion(), request.projectCode(), request.broadcastMessageId()
|
||||
));
|
||||
return fromResponse(plan, response, toolCode == null ? "ENTERPRISE_KNOWLEDGE" : "LIVE_DATA");
|
||||
@@ -216,6 +219,13 @@ public class AihrAgentOrchestrator {
|
||||
"故障", "漏水", "电梯", "现场").stream().anyMatch(value::contains);
|
||||
}
|
||||
|
||||
private static String agentKnowledgeSource(String question) {
|
||||
return AihrFormalPolicyClassifier.requiresFormalSource(
|
||||
AihrFormalPolicyClassifier.MOBILE_AGENT_SOURCE, question)
|
||||
? AihrFormalPolicyClassifier.FORMAL_POLICY_SOURCE
|
||||
: AihrFormalPolicyClassifier.MOBILE_AGENT_SOURCE;
|
||||
}
|
||||
|
||||
private static boolean isConflictBoundaryRequest(String question) {
|
||||
String value = question == null ? "" : question.trim();
|
||||
return value.contains("一份") && value.contains("另一份")
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package org.dromara.aihr.knowledge.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Server-side routing guard for questions that can change an employee's understanding of
|
||||
* policy, authority, fees, safety duties or required records.
|
||||
*
|
||||
* <p>The client never selects this mode. It is only enabled for the authenticated mobile
|
||||
* agent entry point, and the retrieval layer then accepts approved formal sources only.</p>
|
||||
*/
|
||||
public final class AihrFormalPolicyClassifier {
|
||||
|
||||
public static final String MOBILE_AGENT_SOURCE = "mobile_uni_agent";
|
||||
public static final String FORMAL_POLICY_SOURCE = "mobile_uni_agent_formal_policy";
|
||||
|
||||
private static final List<String> FORMAL_TERMS = List.of(
|
||||
"制度", "规定", "要求", "依据", "条款", "适用范围", "版本", "生效",
|
||||
"权限", "授权", "审批", "申请条件", "责任主体", "岗位职责", "预案",
|
||||
"频次", "路线", "留痕", "记录哪些", "计费", "费用构成", "查询入口",
|
||||
"公示", "合规", "必须"
|
||||
);
|
||||
|
||||
private static final List<String> CONTROLLED_DOMAINS = List.of(
|
||||
"日常巡检", "强降雨", "防汛", "大件垃圾", "装修垃圾", "装修产生", "报修受理",
|
||||
"物业费账单", "分期缴费", "空置房", "长期无人居住", "维修一直没进展",
|
||||
"车辆被占位", "车辆堵住", "停车冲突", "安全险情", "突发事件"
|
||||
);
|
||||
|
||||
private AihrFormalPolicyClassifier() {
|
||||
}
|
||||
|
||||
public static boolean requiresFormalSource(String source, String question) {
|
||||
String normalizedSource = source == null ? "" : source.trim().toLowerCase(Locale.ROOT);
|
||||
if (FORMAL_POLICY_SOURCE.equals(normalizedSource)) {
|
||||
return true;
|
||||
}
|
||||
if (!normalizedSource.startsWith(MOBILE_AGENT_SOURCE)) {
|
||||
return false;
|
||||
}
|
||||
String value = question == null ? "" : question.trim();
|
||||
if (value.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return containsAny(value, FORMAL_TERMS) || containsAny(value, CONTROLLED_DOMAINS);
|
||||
}
|
||||
|
||||
private static boolean containsAny(String value, List<String> terms) {
|
||||
return terms.stream().anyMatch(value::contains);
|
||||
}
|
||||
}
|
||||
+42
-7
@@ -143,7 +143,9 @@ public class AihrKnowledgeQueryService {
|
||||
} else {
|
||||
boolean candidateIntent = "QA".equals(context.intent())
|
||||
&& memoryService.isProjectMemoryCandidateIntent(principal, request.queryText());
|
||||
memoryRecall = "QA".equals(context.intent()) && !candidateIntent
|
||||
boolean formalPolicyRequest = AihrFormalPolicyClassifier.requiresFormalSource(
|
||||
routed.source(), routed.queryText());
|
||||
memoryRecall = "QA".equals(context.intent()) && !candidateIntent && !formalPolicyRequest
|
||||
? memoryService.recall(principal, routed.queryText(), routed.limit())
|
||||
: ServiceMemoryRecall.empty();
|
||||
if (memoryRecall == null) {
|
||||
@@ -175,7 +177,8 @@ public class AihrKnowledgeQueryService {
|
||||
}
|
||||
}
|
||||
MemoryCandidateResponse candidate = context.broadcastMessageId() != null
|
||||
|| !"QA".equals(context.intent()) || hasText(routed.toolCode()) ? null
|
||||
|| !"QA".equals(context.intent()) || hasText(routed.toolCode())
|
||||
|| AihrFormalPolicyClassifier.requiresFormalSource(routed.source(), routed.queryText()) ? null
|
||||
: memoryService.detectForQuery(principal, request.queryText(), context.conversationId(), response.requestId(),
|
||||
new SourceSnapshot(sourceType(request.source()), truncate(request.queryText(), 4000),
|
||||
null, null, null, null, null));
|
||||
@@ -299,17 +302,26 @@ public class AihrKnowledgeQueryService {
|
||||
String requestId = UUID.randomUUID().toString().replace("-", "");
|
||||
long started = System.nanoTime();
|
||||
List<String> scopeCodes = spaceCodes(app.tenantId(), spaceIds);
|
||||
boolean formalPolicyOnly = AihrFormalPolicyClassifier.requiresFormalSource(
|
||||
request.source(), request.queryText());
|
||||
String retrievalSource = formalPolicyOnly
|
||||
? AihrFormalPolicyClassifier.FORMAL_POLICY_SOURCE
|
||||
: request.source();
|
||||
try {
|
||||
SearchResponse legacy = sopService.searchAuthorized(new SearchRequest(
|
||||
request.queryText(), request.category(), request.position(), request.source(), request.limit()),
|
||||
request.queryText(), request.category(), request.position(), retrievalSource, request.limit()),
|
||||
principal == null ? "" : principal.extPartyId(), spaceIds);
|
||||
List<Citation> citations = citations(app.tenantId(), spaceIds, legacy.snippets());
|
||||
List<Citation> citations = citations(app.tenantId(), spaceIds, legacy.snippets(), formalPolicyOnly);
|
||||
boolean noEvidence = citations.isEmpty();
|
||||
String answer = formalPolicyOnly && noEvidence
|
||||
? "当前已批准且在有效期内的正式资料中没有找到足够依据,不能使用访谈、经验材料或草稿补全政策答案。"
|
||||
+ "请补充经审核的正式来源后再查询。"
|
||||
: legacy.answer();
|
||||
String status = noEvidence ? "NO_EVIDENCE" : "SUCCESS";
|
||||
long latency = elapsedMillis(started);
|
||||
auditService.record(requestId, principal, app, displayQuery, scopeCodes,
|
||||
citations.isEmpty() ? List.of() : List.of("DOCUMENT"), status, latency, legacy.promptVersion());
|
||||
return new QueryResponse(requestId, displayQuery, legacy.answer(), citations, scopeCodes,
|
||||
return new QueryResponse(requestId, displayQuery, answer, citations, scopeCodes,
|
||||
noEvidence, legacy.promptVersion(), legacy, null);
|
||||
} catch (RuntimeException ex) {
|
||||
auditService.record(requestId, principal, app, displayQuery, scopeCodes, List.of(),
|
||||
@@ -577,7 +589,8 @@ public class AihrKnowledgeQueryService {
|
||||
);
|
||||
}
|
||||
|
||||
private List<Citation> citations(String tenantId, Set<Long> allowedSpaceIds, List<SnippetResponse> snippets) {
|
||||
private List<Citation> citations(String tenantId, Set<Long> allowedSpaceIds, List<SnippetResponse> snippets,
|
||||
boolean formalPolicyOnly) {
|
||||
if (snippets == null || snippets.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
@@ -590,6 +603,26 @@ public class AihrKnowledgeQueryService {
|
||||
args.add(tenantId);
|
||||
args.addAll(allowedSpaceIds);
|
||||
args.addAll(fragmentIds);
|
||||
String formalSourceClause = formalPolicyOnly ? """
|
||||
and exists (
|
||||
select 1
|
||||
from aihr_knowledge_attach governed_attach
|
||||
join aihr_knowledge_source_governance governance
|
||||
on governance.tenant_id = governed_attach.tenant_id
|
||||
and governance.attachment_id = governed_attach.id
|
||||
where governed_attach.tenant_id = f.tenant_id
|
||||
and governed_attach.knowledge_id = f.knowledge_id
|
||||
and governed_attach.doc_id = f.doc_id
|
||||
and governed_attach.status = 2
|
||||
and governance.source_doc_id = f.doc_id
|
||||
and governance.authority_type = 'FORMAL_POLICY'
|
||||
and governance.lifecycle_status = 'APPROVED'
|
||||
and governance.effective_date <= current_date()
|
||||
and (governance.expires_date is null or governance.expires_date >= current_date())
|
||||
and char_length(trim(governance.source_version)) > 0
|
||||
and governance.content_sha256 regexp '^[0-9a-f]{64}$'
|
||||
)
|
||||
""" : "";
|
||||
List<Citation> rows = jdbcTemplate.query("""
|
||||
select k.code as space_code, f.doc_id, coalesce(a.name, k.name) as title,
|
||||
f.content, f.id as fragment_id
|
||||
@@ -600,7 +633,9 @@ public class AihrKnowledgeQueryService {
|
||||
where f.tenant_id = ?
|
||||
and f.knowledge_id in (%s)
|
||||
and f.id in (%s)
|
||||
""".formatted(placeholders(allowedSpaceIds.size()), placeholders(fragmentIds.size())),
|
||||
%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"),
|
||||
truncate(rs.getString("content"), 500), rs.getLong("fragment_id")
|
||||
|
||||
+78
-9
@@ -40,6 +40,7 @@ import org.dromara.aihr.domain.AihrSopDto.VectorizeResponse;
|
||||
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.UnbindDocumentResponse;
|
||||
import org.dromara.aihr.knowledge.parse.KnowledgeDocumentParser;
|
||||
import org.dromara.aihr.knowledge.parse.TikaKnowledgeDocumentParser;
|
||||
import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier;
|
||||
import org.dromara.aihr.personal.service.EnterpriseKnowledgeAccessPolicy.EnterpriseKnowledgeGrant;
|
||||
import org.dromara.aihr.personal.support.PersonalOwner;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
@@ -214,7 +215,9 @@ public class AihrSopSeedService {
|
||||
if (isBlank(queryText)) {
|
||||
return withReviewId(noEvidenceResponse("", "sop"), source, requester);
|
||||
}
|
||||
SearchResponse response = dbSearch("", queryText, request == null ? null : request.limit(), allowedKnowledgeIds);
|
||||
boolean formalPolicyOnly = AihrFormalPolicyClassifier.FORMAL_POLICY_SOURCE.equals(source);
|
||||
SearchResponse response = dbSearch(
|
||||
"", queryText, request == null ? null : request.limit(), allowedKnowledgeIds, formalPolicyOnly);
|
||||
if (response == null || isNoEvidenceAnswer(response.answer())) {
|
||||
recordKnowledgeGap(queryText, "sop", position, source);
|
||||
response = noEvidenceResponse(queryText, "sop");
|
||||
@@ -2511,8 +2514,14 @@ public class AihrSopSeedService {
|
||||
}
|
||||
|
||||
private SearchResponse dbSearch(String category, String queryText, Integer limit, Set<Long> allowedKnowledgeIds) {
|
||||
return dbSearch(category, queryText, limit, allowedKnowledgeIds, false);
|
||||
}
|
||||
|
||||
private SearchResponse dbSearch(String category, String queryText, Integer limit,
|
||||
Set<Long> allowedKnowledgeIds, boolean formalPolicyOnly) {
|
||||
try {
|
||||
List<KnowledgeHit> hits = dbHits(category, queryText, limit, allowedKnowledgeIds);
|
||||
List<KnowledgeHit> hits = dbHits(
|
||||
category, queryText, limit, allowedKnowledgeIds, formalPolicyOnly);
|
||||
if (hits.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
@@ -2897,10 +2906,18 @@ public class AihrSopSeedService {
|
||||
}
|
||||
|
||||
private List<KnowledgeHit> dbHits(String category, String queryText, Integer limit, Set<Long> allowedKnowledgeIds) {
|
||||
return dbHits(category, queryText, limit, allowedKnowledgeIds, false);
|
||||
}
|
||||
|
||||
private List<KnowledgeHit> dbHits(String category, String queryText, Integer limit,
|
||||
Set<Long> allowedKnowledgeIds, boolean formalPolicyOnly) {
|
||||
int limitValue = limitValue(limit);
|
||||
List<KnowledgeHit> keywordHits = likeHits(category, queryText, limitValue, allowedKnowledgeIds);
|
||||
List<KnowledgeHit> textHits = rrf(keywordHits, fulltextHits(category, queryText, limitValue, allowedKnowledgeIds), limitValue);
|
||||
return rerankHits(queryText, rrf(textHits, vectorHits(category, queryText, limitValue, allowedKnowledgeIds), limitValue));
|
||||
List<KnowledgeHit> keywordHits = likeHits(
|
||||
category, queryText, limitValue, allowedKnowledgeIds, formalPolicyOnly);
|
||||
List<KnowledgeHit> textHits = rrf(keywordHits, fulltextHits(
|
||||
category, queryText, limitValue, allowedKnowledgeIds, formalPolicyOnly), limitValue);
|
||||
return rerankHits(queryText, rrf(textHits, vectorHits(
|
||||
category, queryText, limitValue, allowedKnowledgeIds, formalPolicyOnly), limitValue));
|
||||
}
|
||||
|
||||
private static String knowledgeScopeSql(String alias, Set<Long> allowedKnowledgeIds) {
|
||||
@@ -2920,6 +2937,32 @@ public class AihrSopSeedService {
|
||||
}
|
||||
}
|
||||
|
||||
private static String formalSourceScopeSql(String fragmentAlias, boolean formalPolicyOnly) {
|
||||
if (!formalPolicyOnly) {
|
||||
return "";
|
||||
}
|
||||
return """
|
||||
and exists (
|
||||
select 1
|
||||
from aihr_knowledge_attach governed_attach
|
||||
join aihr_knowledge_source_governance governance
|
||||
on governance.tenant_id = governed_attach.tenant_id
|
||||
and governance.attachment_id = governed_attach.id
|
||||
where governed_attach.tenant_id = %1$s.tenant_id
|
||||
and governed_attach.knowledge_id = %1$s.knowledge_id
|
||||
and governed_attach.doc_id = %1$s.doc_id
|
||||
and governed_attach.status = 2
|
||||
and governance.source_doc_id = %1$s.doc_id
|
||||
and governance.authority_type = 'FORMAL_POLICY'
|
||||
and governance.lifecycle_status = 'APPROVED'
|
||||
and governance.effective_date <= current_date()
|
||||
and (governance.expires_date is null or governance.expires_date >= current_date())
|
||||
and char_length(trim(governance.source_version)) > 0
|
||||
and governance.content_sha256 regexp '^[0-9a-f]{64}$'
|
||||
)
|
||||
""".formatted(fragmentAlias);
|
||||
}
|
||||
|
||||
private FeedbackFilterResult filterDownFeedback(String queryText, List<KnowledgeHit> hits) {
|
||||
return filterDownFeedback(hits, downFeedbackFragmentIds(queryText));
|
||||
}
|
||||
@@ -2963,6 +3006,11 @@ public class AihrSopSeedService {
|
||||
}
|
||||
|
||||
private List<KnowledgeHit> vectorHits(String category, String queryText, int limit, Set<Long> allowedKnowledgeIds) {
|
||||
return vectorHits(category, queryText, limit, allowedKnowledgeIds, false);
|
||||
}
|
||||
|
||||
private List<KnowledgeHit> vectorHits(String category, String queryText, int limit,
|
||||
Set<Long> allowedKnowledgeIds, boolean formalPolicyOnly) {
|
||||
for (EmbeddingRuntime runtime : embeddingRuntimes()) {
|
||||
try {
|
||||
List<String> embeddings = callEmbeddings(runtime, List.of(queryText));
|
||||
@@ -2972,7 +3020,7 @@ public class AihrSopSeedService {
|
||||
List<VectorMatch> matches = queryQdrant(category, embeddings.get(0), limit, allowedKnowledgeIds);
|
||||
List<KnowledgeHit> hits = new ArrayList<>();
|
||||
for (VectorMatch match : matches) {
|
||||
hydrateQdrantHit(match, allowedKnowledgeIds).ifPresent(hits::add);
|
||||
hydrateQdrantHit(match, allowedKnowledgeIds, formalPolicyOnly).ifPresent(hits::add);
|
||||
}
|
||||
if (!hits.isEmpty()) {
|
||||
return hits;
|
||||
@@ -2989,6 +3037,11 @@ public class AihrSopSeedService {
|
||||
}
|
||||
|
||||
private List<KnowledgeHit> fulltextHits(String category, String queryText, int limit, Set<Long> allowedKnowledgeIds) {
|
||||
return fulltextHits(category, queryText, limit, allowedKnowledgeIds, false);
|
||||
}
|
||||
|
||||
private List<KnowledgeHit> fulltextHits(String category, String queryText, int limit,
|
||||
Set<Long> allowedKnowledgeIds, boolean formalPolicyOnly) {
|
||||
String sql = """
|
||||
select f.id as fragment_id,
|
||||
coalesce(a.name, i.name) as title,
|
||||
@@ -3002,12 +3055,14 @@ public class AihrSopSeedService {
|
||||
join aihr_knowledge_info i on i.id = f.knowledge_id and i.tenant_id = f.tenant_id
|
||||
left join aihr_knowledge_attach a on a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id and a.tenant_id = f.tenant_id
|
||||
where f.tenant_id = ?
|
||||
%s
|
||||
%s
|
||||
and (? = '' or i.name = ?)
|
||||
and match(f.content) against (? in natural language mode)
|
||||
order by score desc, f.idx asc
|
||||
limit ?
|
||||
""".formatted(knowledgeScopeSql("f", allowedKnowledgeIds));
|
||||
""".formatted(knowledgeScopeSql("f", allowedKnowledgeIds),
|
||||
formalSourceScopeSql("f", formalPolicyOnly));
|
||||
List<Object> args = new ArrayList<>();
|
||||
args.add(queryText);
|
||||
args.add(tenantId());
|
||||
@@ -3033,6 +3088,11 @@ public class AihrSopSeedService {
|
||||
}
|
||||
|
||||
private List<KnowledgeHit> likeHits(String category, String queryText, int limit, Set<Long> allowedKnowledgeIds) {
|
||||
return likeHits(category, queryText, limit, allowedKnowledgeIds, false);
|
||||
}
|
||||
|
||||
private List<KnowledgeHit> likeHits(String category, String queryText, int limit,
|
||||
Set<Long> allowedKnowledgeIds, boolean formalPolicyOnly) {
|
||||
List<String> terms = searchTerms(queryText);
|
||||
if (terms.isEmpty()) {
|
||||
return List.of();
|
||||
@@ -3056,12 +3116,14 @@ public class AihrSopSeedService {
|
||||
join aihr_knowledge_info i on i.id = f.knowledge_id and i.tenant_id = f.tenant_id
|
||||
left join aihr_knowledge_attach a on a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id and a.tenant_id = f.tenant_id
|
||||
where f.tenant_id = ?
|
||||
%s
|
||||
%s
|
||||
and (? = '' or i.name = ?)
|
||||
and (%s)
|
||||
order by score desc, f.idx asc
|
||||
limit ?
|
||||
""".formatted(String.join(" + ", scoreParts), knowledgeScopeSql("f", allowedKnowledgeIds), String.join(" or ", whereParts));
|
||||
""".formatted(String.join(" + ", scoreParts), knowledgeScopeSql("f", allowedKnowledgeIds),
|
||||
formalSourceScopeSql("f", formalPolicyOnly), String.join(" or ", whereParts));
|
||||
List<Object> args = new ArrayList<>();
|
||||
terms.stream().map(AihrSopSeedService::likePattern).forEach(args::add);
|
||||
args.add(tenantId());
|
||||
@@ -3087,6 +3149,11 @@ public class AihrSopSeedService {
|
||||
}
|
||||
|
||||
private Optional<KnowledgeHit> hydrateQdrantHit(VectorMatch match, Set<Long> allowedKnowledgeIds) {
|
||||
return hydrateQdrantHit(match, allowedKnowledgeIds, false);
|
||||
}
|
||||
|
||||
private Optional<KnowledgeHit> hydrateQdrantHit(VectorMatch match, Set<Long> allowedKnowledgeIds,
|
||||
boolean formalPolicyOnly) {
|
||||
String sql = """
|
||||
select f.id as fragment_id,
|
||||
coalesce(a.name, i.name) as title,
|
||||
@@ -3099,11 +3166,13 @@ public class AihrSopSeedService {
|
||||
join aihr_knowledge_info i on i.id = f.knowledge_id and i.tenant_id = f.tenant_id
|
||||
left join aihr_knowledge_attach a on a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id and a.tenant_id = f.tenant_id
|
||||
where f.tenant_id = ?
|
||||
%s
|
||||
%s
|
||||
and f.doc_id = ?
|
||||
and f.idx = ?
|
||||
limit 1
|
||||
""".formatted(knowledgeScopeSql("f", allowedKnowledgeIds));
|
||||
""".formatted(knowledgeScopeSql("f", allowedKnowledgeIds),
|
||||
formalSourceScopeSql("f", formalPolicyOnly));
|
||||
List<Object> args = new ArrayList<>();
|
||||
args.add(tenantId());
|
||||
addKnowledgeScopeArgs(args, allowedKnowledgeIds);
|
||||
|
||||
+11
@@ -9,6 +9,7 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
|
||||
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse;
|
||||
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
|
||||
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService;
|
||||
import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier;
|
||||
import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.CurrentTaskSummary;
|
||||
import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse;
|
||||
import org.dromara.aihr.memory.AihrMemoryDto.MemoryDraft;
|
||||
@@ -65,6 +66,16 @@ class AihrAgentOrchestratorTest {
|
||||
assertTrue(result.sourceSummary().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void originalPolicyQuestionSelectsFormalSourceBeforePlannerRewrite() {
|
||||
var query = new CapturingQueryService(response("正式制度答案", null, false));
|
||||
var service = orchestrator(query);
|
||||
|
||||
service.handle(request("住户提出分期缴费时,申请条件、审批角色和留痕要求是什么?"));
|
||||
|
||||
assertEquals(AihrFormalPolicyClassifier.FORMAL_POLICY_SOURCE, query.lastRequest.source());
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitRuleConflictStopsBeforeKnowledgeChoosesASide() {
|
||||
var query = new CapturingQueryService(response("unused", null, false));
|
||||
|
||||
+42
@@ -9,6 +9,7 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
|
||||
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
|
||||
import org.dromara.aihr.knowledge.service.AihrKnowledgeAccessService;
|
||||
import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService;
|
||||
import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier;
|
||||
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
|
||||
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryAuditService;
|
||||
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService;
|
||||
@@ -147,6 +148,47 @@ class AihrKnowledgeQueryServiceTest {
|
||||
assertEquals(List.of("mt_customer_service"), result.usedSpaceCodes());
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
void mobilePolicyQuestionUsesFormalRegistryAndFailsClosedWhenCitationRevalidationFindsNone() {
|
||||
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 memory = mock(AihrMemoryService.class);
|
||||
var principal = new AihrKnowledgePrincipal(
|
||||
"000000", 1L, "app_user", "employee-1", Set.of("employee"), Set.of(), "app");
|
||||
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
|
||||
when(resolver.current()).thenReturn(principal);
|
||||
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
|
||||
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(9L));
|
||||
when(jdbc.queryForList(any(String.class), any(Class.class), any(Object[].class)))
|
||||
.thenReturn(List.of("sop"));
|
||||
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(List.of());
|
||||
when(sop.searchAuthorized(any(), any(), any())).thenReturn(
|
||||
new SearchResponse("分期缴费", "sop", "经验材料给出的分期建议", "", List.of(),
|
||||
List.of(new SnippetResponse("经验萃取", "不应进入正式答案", 99L)),
|
||||
List.of(), List.of(), List.of(), List.of(), "test-v1", null));
|
||||
var service = new AihrKnowledgeQueryService(
|
||||
resolver, appService, access, sop, mock(AihrKnowledgeQueryAuditService.class), jdbc,
|
||||
mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class),
|
||||
memory);
|
||||
|
||||
var result = service.queryInternal(new QueryRequest(
|
||||
"住户提出分期缴费时,申请条件、审批角色和留痕要求是什么?",
|
||||
List.of(), "sop", null, "mobile_uni_agent", 5, null));
|
||||
|
||||
var requestCaptor = ArgumentCaptor.forClass(SearchRequest.class);
|
||||
verify(sop).searchAuthorized(requestCaptor.capture(), any(), eq(Set.of(9L)));
|
||||
assertEquals(AihrFormalPolicyClassifier.FORMAL_POLICY_SOURCE, requestCaptor.getValue().source());
|
||||
assertTrue(result.noEvidence());
|
||||
assertTrue(result.citations().isEmpty());
|
||||
assertTrue(result.answer().contains("不能使用访谈、经验材料或草稿"));
|
||||
verify(memory, never()).recall(any(), anyString(), any());
|
||||
verify(memory, never()).detectForQuery(any(), anyString(), any(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void internalToolFailureNeverFallsBackToRag() {
|
||||
var resolver = mock(AihrKnowledgePrincipalResolver.class);
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package org.dromara.aihr.knowledge.service;
|
||||
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@Tag("dev")
|
||||
class AihrFormalPolicyClassifierTest {
|
||||
|
||||
@Test
|
||||
void augustFirstNormalAndSynonymQuestionsRequireFormalSources() {
|
||||
List<String> questions = List.of(
|
||||
"不同住宅业态的日常巡检频次、路线和留痕要求分别是什么?",
|
||||
"收到强降雨预警后,生活顾问应按什么顺序巡查、上报和闭环?",
|
||||
"大件垃圾和装修垃圾应如何登记、分类、清运和说明费用?",
|
||||
"报修受理后,生活顾问需要记录哪些信息并如何完成回访闭环?",
|
||||
"物业费账单的费用构成、计费期间和查询入口如何解释?",
|
||||
"住户提出分期缴费时,申请条件、审批角色和留痕要求是什么?",
|
||||
"长期无人居住的房屋平时怎么做安全查看?",
|
||||
"维修一直没进展,管家应该怎么催办并给业主回话?",
|
||||
"业主一直欠费又说服务差,日常怎么沟通才合规?",
|
||||
"装修产生的废料该找谁、怎么约清运、能不能收费?",
|
||||
"车辆被占位或堵住时,物业怎么协调才不会升级冲突?",
|
||||
"现场突然有安全险情,第一时间该找谁、留下什么记录?"
|
||||
);
|
||||
|
||||
questions.forEach(question -> assertTrue(
|
||||
AihrFormalPolicyClassifier.requiresFormalSource("mobile_uni_agent", question),
|
||||
question
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adviceAndOtherEntryPointsDoNotGainFormalPolicySemantics() {
|
||||
assertFalse(AihrFormalPolicyClassifier.requiresFormalSource(
|
||||
"mobile_uni_agent", "催费话术怎么说?"));
|
||||
assertFalse(AihrFormalPolicyClassifier.requiresFormalSource(
|
||||
"mobile_uni_agent", "可以帮我写一句催费话术吗?"));
|
||||
assertFalse(AihrFormalPolicyClassifier.requiresFormalSource(
|
||||
"mobile_uni_agent", "图片里有什么?"));
|
||||
assertFalse(AihrFormalPolicyClassifier.requiresFormalSource(
|
||||
"knowledge_admin", "这个制度有哪些要求?"));
|
||||
assertTrue(AihrFormalPolicyClassifier.requiresFormalSource(
|
||||
"mobile_uni_agent_formal_policy", "继续说明"));
|
||||
}
|
||||
}
|
||||
+6
@@ -490,6 +490,12 @@ public class AihrSopSeedServiceTest {
|
||||
assertTrue(code.contains("searchAuthorized(SearchRequest request, String requesterExtPartyId, Set<Long> allowedKnowledgeIds)"));
|
||||
assertTrue(code.contains(".knowledge_id in ("));
|
||||
assertTrue(code.contains("qdrantMatchAny(\"knowledge_id\", knowledgeIds)"));
|
||||
assertTrue(code.contains("aihr_knowledge_source_governance"));
|
||||
assertTrue(code.contains("governance.source_doc_id"));
|
||||
assertTrue(code.contains("governance.authority_type = 'FORMAL_POLICY'"));
|
||||
assertTrue(code.contains("governance.lifecycle_status = 'APPROVED'"));
|
||||
assertTrue(code.contains("governance.effective_date <= current_date()"));
|
||||
assertTrue(code.contains("governance.content_sha256 regexp '^[0-9a-f]{64}$'"));
|
||||
|
||||
AihrSopSeedService service = new AihrSopSeedService(
|
||||
new ObjectMapper(), mock(JdbcTemplate.class), null, "", null, null, mock(AihrModelSeedService.class));
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
-- 2026-08-01 受控内部试点正式来源白名单。
|
||||
-- 必须先独立核验原文件 SHA-256,再执行本文件;本文件只会命中精确的附件 ID + doc_id。
|
||||
-- 访谈、经验萃取、AI 生成内容和草稿不得加入此清单。
|
||||
|
||||
INSERT INTO aihr_knowledge_source_governance (
|
||||
tenant_id, source_id, attachment_id, source_doc_id, authority_type, lifecycle_status,
|
||||
source_version, effective_date, expires_date, applicability_scope, content_sha256,
|
||||
approval_basis, approved_by, approved_time
|
||||
)
|
||||
SELECT a.tenant_id, source_seed.source_id, a.id, a.doc_id, 'FORMAL_POLICY', 'APPROVED',
|
||||
source_seed.source_version, source_seed.effective_date, source_seed.expires_date,
|
||||
source_seed.applicability_scope, source_seed.content_sha256,
|
||||
'2026-08-01受控内部试点正式来源白名单', 'PROJECT_SPONSOR_DELEGATION', '2026-07-30 23:59:00'
|
||||
FROM aihr_knowledge_attach a
|
||||
JOIN (
|
||||
SELECT 1883 attachment_id, '017e4a5dfadd40279bbe5f947cfdf5e1' source_doc_id,
|
||||
'FORMAL-ZZKF01-20240919' source_id, '2024-09-19' source_version,
|
||||
DATE '2024-09-19' effective_date, NULL expires_date,
|
||||
'住宅物业/生活顾问网格化作业' applicability_scope,
|
||||
'f79a9a3f29f73d518761aa3e8707928a10b8a9ce6b56dd53f30ef73219c0c9db' content_sha256
|
||||
UNION ALL SELECT 1884, 'd544749eb13b456aaa06f0fd6c84dfc0',
|
||||
'FORMAL-ZZKF02-20240919', '2024-09-19', DATE '2024-09-19', NULL,
|
||||
'住宅物业/客户报事报修作业',
|
||||
'1cbecb71e28750a033c683928745ef0de3e2cf688ee8d838806f098b4a0e0182'
|
||||
UNION ALL SELECT 1885, '77a84c25c1c14b80b74c6211a76a3fa1',
|
||||
'FORMAL-ZZKF03-20240919', '2024-09-19', DATE '2024-09-19', NULL,
|
||||
'住宅物业/客户投诉处理作业',
|
||||
'eb6b7efa269cb46feba14d7aeb65f41bbb15352a05cdb8d092c781e9a4328ed9'
|
||||
UNION ALL SELECT 1886, '22a68eda3b6e476192224ad54a47d0a3',
|
||||
'FORMAL-ZZKF05-20240919', '2024-09-19', DATE '2024-09-19', NULL,
|
||||
'住宅物业/装修管理作业',
|
||||
'bf822957ba738ef5075e465ed5da4a279e1f37a0b141109425049a72b0c71550'
|
||||
UNION ALL SELECT 1922, '4e484a61c8ab473cadab75e61a672fe8',
|
||||
'FORMAL-ZZBJ06-20240919', '2024-09-19', DATE '2024-09-19', NULL,
|
||||
'住宅物业/垃圾收集和清运作业',
|
||||
'df3a35f6c6a624fc06c7807f6b8b7cf9e7e3c17bba974ae1c5850cf7c2483c2c'
|
||||
UNION ALL SELECT 1927, '954f717921ed43b5882ec8be7a9886ab',
|
||||
'FORMAL-ZZBJ12-20240919', '2024-09-19', DATE '2024-09-19', NULL,
|
||||
'住宅物业/突发事件应急作业',
|
||||
'739db239f623854b89c8a57a43484f86d244d2d126e0d623f2773b430d63b7b5'
|
||||
UNION ALL SELECT 1932, 'f448a336c14c4d6790e423abf7beb4fd',
|
||||
'FORMAL-ZZAQ04-20240919', '2024-09-19', DATE '2024-09-19', NULL,
|
||||
'住宅物业/车辆管理作业',
|
||||
'3d1e74a9abec8c2da43c95f42a877e41a93f22aa794ad75b65a1d5056a71b4f2'
|
||||
UNION ALL SELECT 1947, 'c3402e7a86504b188807f548277576ce',
|
||||
'FORMAL-YC-2026-33', '银城物业[2026]第33号', DATE '2026-05-26', NULL,
|
||||
'银城物业各事业部、公司/2026年汛期防台防汛',
|
||||
'824e6355f5506ab113919f4744a13a68c9356af0e946347bb159804e0e32feb8'
|
||||
UNION ALL SELECT 1984, 'f5bd66670ee345aa96e03c328e0753e8',
|
||||
'FORMAL-LIFE-ADVISOR-HANDBOOK-20220928', '2022-09-28', DATE '2022-09-28', NULL,
|
||||
'生活顾问岗位学习与作业参考',
|
||||
'362b9a2f01454a16eaf8fcd2cf76fe283f0dc2ced99533ade4a6ae8cc1f480fa'
|
||||
UNION ALL SELECT 1990, '31b7249eaa3c4b8d8ca59738edc1fc37',
|
||||
'FORMAL-NJ-HOUSING-CONTRACT-2025-134', '宁房办[2025]134号', DATE '2025-12-16', NULL,
|
||||
'南京市住宅小区物业服务合同示范文本适用事项',
|
||||
'9a61349811d292d7d2e75e66384e60a0e2e30f66b92d8a43557127e07b983534'
|
||||
) source_seed
|
||||
ON source_seed.attachment_id = a.id
|
||||
AND source_seed.source_doc_id = a.doc_id
|
||||
WHERE a.tenant_id = '000000'
|
||||
AND a.status = 2
|
||||
ON DUPLICATE KEY UPDATE
|
||||
source_doc_id = VALUES(source_doc_id),
|
||||
authority_type = VALUES(authority_type),
|
||||
lifecycle_status = VALUES(lifecycle_status),
|
||||
source_version = VALUES(source_version),
|
||||
effective_date = VALUES(effective_date),
|
||||
expires_date = VALUES(expires_date),
|
||||
applicability_scope = VALUES(applicability_scope),
|
||||
content_sha256 = VALUES(content_sha256),
|
||||
approval_basis = VALUES(approval_basis),
|
||||
approved_by = VALUES(approved_by),
|
||||
approved_time = VALUES(approved_time);
|
||||
|
||||
-- 失败关闭:10 个精确来源未全部注册时让 mysql 进程返回非零。
|
||||
DROP TEMPORARY TABLE IF EXISTS aihr_aug1_formal_source_assert;
|
||||
CREATE TEMPORARY TABLE aihr_aug1_formal_source_assert (
|
||||
assertion_value tinyint NOT NULL
|
||||
);
|
||||
INSERT INTO aihr_aug1_formal_source_assert (assertion_value)
|
||||
SELECT CASE WHEN COUNT(*) = 10 THEN 1 ELSE NULL END
|
||||
FROM aihr_knowledge_source_governance
|
||||
WHERE tenant_id = '000000'
|
||||
AND source_id IN (
|
||||
'FORMAL-ZZKF01-20240919', 'FORMAL-ZZKF02-20240919', 'FORMAL-ZZKF03-20240919',
|
||||
'FORMAL-ZZKF05-20240919', 'FORMAL-ZZBJ06-20240919', 'FORMAL-ZZBJ12-20240919',
|
||||
'FORMAL-ZZAQ04-20240919', 'FORMAL-YC-2026-33',
|
||||
'FORMAL-LIFE-ADVISOR-HANDBOOK-20220928', 'FORMAL-NJ-HOUSING-CONTRACT-2025-134'
|
||||
)
|
||||
AND authority_type = 'FORMAL_POLICY'
|
||||
AND lifecycle_status = 'APPROVED'
|
||||
AND effective_date <= current_date()
|
||||
AND (expires_date IS NULL OR expires_date >= current_date())
|
||||
AND CHAR_LENGTH(TRIM(source_version)) > 0
|
||||
AND content_sha256 REGEXP '^[0-9a-f]{64}$';
|
||||
DROP TEMPORARY TABLE aihr_aug1_formal_source_assert;
|
||||
@@ -0,0 +1,62 @@
|
||||
-- 正式知识来源治理:附件级权威性、发布状态、版本、生效期和原文件哈希。
|
||||
-- 只创建治理契约,不把任何既有附件自动认定为正式来源。
|
||||
-- 可重复执行;生产请求只读校验,不允许在运行时补表。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_knowledge_source_governance` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
|
||||
`source_id` varchar(80) NOT NULL COMMENT '稳定来源编号',
|
||||
`attachment_id` bigint NOT NULL COMMENT '知识附件ID',
|
||||
`source_doc_id` varchar(80) NOT NULL COMMENT '审批时附件文档ID快照',
|
||||
`authority_type` varchar(30) NOT NULL COMMENT 'FORMAL_POLICY/TRAINING/EXPERIENCE/INTERVIEW/DRAFT',
|
||||
`lifecycle_status` varchar(20) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/APPROVED/REJECTED/RETIRED',
|
||||
`source_version` varchar(80) NOT NULL COMMENT '正式版本或发文号',
|
||||
`effective_date` date NOT NULL COMMENT '生效日期',
|
||||
`expires_date` date DEFAULT NULL COMMENT '失效日期',
|
||||
`applicability_scope` varchar(500) NOT NULL COMMENT '适用范围',
|
||||
`content_sha256` char(64) NOT NULL COMMENT '审批时原文件SHA-256',
|
||||
`approval_basis` varchar(255) NOT NULL COMMENT '审批依据或受控试点决策',
|
||||
`approved_by` varchar(100) DEFAULT NULL COMMENT '审批主体',
|
||||
`approved_time` datetime DEFAULT NULL COMMENT '审批时间',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_knowledge_source_id` (`tenant_id`, `source_id`),
|
||||
UNIQUE KEY `uk_aihr_knowledge_source_attachment` (`tenant_id`, `attachment_id`),
|
||||
KEY `idx_aihr_knowledge_source_retrieval`
|
||||
(`tenant_id`, `authority_type`, `lifecycle_status`, `effective_date`, `expires_date`),
|
||||
KEY `idx_aihr_knowledge_source_doc` (`tenant_id`, `source_doc_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
COMMENT='AIHR知识附件来源治理';
|
||||
|
||||
-- 与租户事实表保持同一排序规则,兼容历史库和全新 MySQL 8 数据库。
|
||||
SET @formal_source_target_collation := (
|
||||
SELECT collation_name FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'aihr_knowledge_info'
|
||||
AND column_name = 'tenant_id'
|
||||
LIMIT 1
|
||||
);
|
||||
SET @formal_source_target_collation := COALESCE(@formal_source_target_collation, @@collation_database);
|
||||
SET @formal_source_table_collation := (
|
||||
SELECT table_collation FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'aihr_knowledge_source_governance'
|
||||
LIMIT 1
|
||||
);
|
||||
SET @formal_source_ddl := IF(
|
||||
@formal_source_table_collation = @formal_source_target_collation,
|
||||
'SELECT 1',
|
||||
CONCAT(
|
||||
'ALTER TABLE aihr_knowledge_source_governance ',
|
||||
'CONVERT TO CHARACTER SET utf8mb4 COLLATE ',
|
||||
@formal_source_target_collation
|
||||
)
|
||||
);
|
||||
PREPARE aihr_formal_source_stmt FROM @formal_source_ddl;
|
||||
EXECUTE aihr_formal_source_stmt;
|
||||
DEALLOCATE PREPARE aihr_formal_source_stmt;
|
||||
|
||||
SET @formal_source_target_collation := NULL;
|
||||
SET @formal_source_table_collation := NULL;
|
||||
SET @formal_source_ddl := NULL;
|
||||
@@ -58,7 +58,7 @@ curl -fsS "$API_BASE/api/aihr/agent/messages/media" \
|
||||
|
||||
文字问题去空白后必须为 1–1000 字;媒体接口的 `question` 为空时使用“请分析附件内容”。请求不接受身份、角色、`toolCode`、知识空间 ID、SQL 或任意 URL。响应继续使用若依信封,`data.status` 只会是 `COMPLETED/NEEDS_INPUT/NEEDS_CONFIRMATION/NO_EVIDENCE/FORBIDDEN/UNAVAILABLE/FAILED`,按钮分别读取 `actionDraft/clarification/resources/nextActions`,不得解析 `answer` 猜状态。
|
||||
|
||||
政策边界在检索前由服务端确定性执行:明确存在版本/规则冲突时返回 `NEEDS_INPUT`,不得自行裁决或拼接;明确无依据时返回 `NO_EVIDENCE`,不得用经验补全政策事实;跨项目、他人敏感信息、无权附件或未发布制度返回 `FORBIDDEN`。已经取得授权片段时,摘要模型的拒答文本不能抹掉这些片段,服务端会回退为受控原片段摘要并保留引用;但“有引用”只证明检索命中,不证明该文件已被业务登记为正式制度。正式政策验收必须另行核对来源版本、生效日、适用范围和审核结论,访谈、经验萃取、seed 或 AI 输出不得登记为 `formalPolicy=true`。
|
||||
政策边界在检索前由服务端确定性执行:明确存在版本/规则冲突时返回 `NEEDS_INPUT`,不得自行裁决或拼接;明确无依据时返回 `NO_EVIDENCE`,不得用经验补全政策事实;跨项目、他人敏感信息、无权附件或未发布制度返回 `FORBIDDEN`。移动端 Agent 对涉及制度、权限、费用、安全职责或必留记录的问题,服务端会在规划改写前固定为正式政策模式,客户端不能选择或绕过;MySQL 模糊/全文召回、Qdrant 命中回表和最终引用装配均只接受 `aihr_knowledge_source_governance` 中当前有效的 `FORMAL_POLICY + APPROVED` 附件,并校验审批时 `docId` 快照一致、版本和原文件 SHA-256 登记完整。正式问题不混入个人/项目记忆;治理表缺失、未审批、已失效、版本或哈希为空,以及最终引用重校验失败时统一返回 `NO_EVIDENCE`。普通“经验话术怎么说”等建议问题仍可检索经授权经验材料,但不得包装为制度。访谈、经验萃取、seed 或 AI 输出不得登记为正式政策来源。
|
||||
|
||||
全网意图在 `externalConsent=false` 时以业务成功响应返回 `NEEDS_INPUT`,用户同意后原请求携带 `externalConsent=true` 重试。确认式记忆先取得 `actionDraft.draftId`,再提交卡片中可编辑的完整 `draft`:
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih
|
||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_web_search_qwen_mysql8.sql
|
||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260723_tenant_org_governance_mysql8.sql
|
||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260722_knowledge_category_mysql8.sql
|
||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260730_formal_knowledge_source_governance_mysql8.sql
|
||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260722_broadcast_mysql8.sql
|
||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260723_broadcast_targeting_mysql8.sql
|
||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260724_direct_feedback_mysql8.sql
|
||||
@@ -142,7 +143,9 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih
|
||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_personal_knowledge_mysql8.sql
|
||||
```
|
||||
|
||||
顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;五岗位题库之后的内容运营迁移为每个场景补齐唯一的初始 Rubric、五个稳定评分维度与训练时评分规则快照列,并只修正仍处于待训练状态的旧每日题场景编号,不重写已完成记录。紧随其后的成长目录迁移只在既有场景和会话表上增加岗位/层级/能力项/审核状态与会话快照,以及复盘会话专用的 `growth_confirmation_level/growth_confirmed_by/growth_confirmed_time`;它只允许记录主管对当前会话下一训练层级的确认,不改写原训练快照或人事结论,并补齐/修复派发请求的 `(tenant_id,request_key,ext_party_id)` 唯一键与最近内容过滤索引。它保留既有启停可用性和历史会话,不把迁移结果写成业务内容签字。生产环境不得设置 `AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP=true`:应用请求只读校验表、列和关键索引,任何缺失都必须先执行本节正式 SQL,不能由任一用户请求触发补列、补索引或状态回填。岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。直通车迁移创建点对点反馈表,并给正式租户 `000000` 幂等预置总裁、财务、人力、审计、运营五个处理角色;人员仍须由管理员按职责分配,业务匿名不抹除内部审计身份。公司文件迁移创建异步提炼表并给消息增加可空 `attachment_id`,不迁移或公开已有文件;紧随其后的多岗位解读迁移只在附件表增加生成状态、经引用校验的结构化结果和规则版本,并建立异步队列索引,不回填旧文件、不改变文件 READY 或消息发布状态。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。Agent 迁移只新增最小运行审计表,不保存问题、答案、密钥或模型推理。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。
|
||||
8 月 1 日受控内部试点的正式来源注册是独立的业务数据变更,不随通用 schema 自动执行。先逐一核对附件 `id + doc_id`、原文件 SHA-256、发文号/版本、生效日期和适用范围,再由已授权负责人执行 `backend/script/sql/ops/aihr_20260730_aug1_formal_source_registry_mysql8.sql`。执行后必须只读确认恰好 10 条 `FORMAL_POLICY + APPROVED` 记录;任何缺失都保持无正式依据,不得把访谈、经验萃取、AI 生成内容或草稿补进白名单。
|
||||
|
||||
顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;五岗位题库之后的内容运营迁移为每个场景补齐唯一的初始 Rubric、五个稳定评分维度与训练时评分规则快照列,并只修正仍处于待训练状态的旧每日题场景编号,不重写已完成记录。紧随其后的成长目录迁移只在既有场景和会话表上增加岗位/层级/能力项/审核状态与会话快照,以及复盘会话专用的 `growth_confirmation_level/growth_confirmed_by/growth_confirmed_time`;它只允许记录主管对当前会话下一训练层级的确认,不改写原训练快照或人事结论,并补齐/修复派发请求的 `(tenant_id,request_key,ext_party_id)` 唯一键与最近内容过滤索引。它保留既有启停可用性和历史会话,不把迁移结果写成业务内容签字。生产环境不得设置 `AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP=true`:应用请求只读校验表、列和关键索引,任何缺失都必须先执行本节正式 SQL,不能由任一用户请求触发补列、补索引或状态回填。岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;正式知识来源治理迁移只创建附件级权威类型、生命周期、版本、生效期、适用范围和原文件哈希契约,不自动批准任何历史附件。移动端政策问答只使用有效的 `FORMAL_POLICY + APPROVED` 注册项,治理表缺失、来源未批准、已失效、版本或哈希为空时均按无正式依据关闭。银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。直通车迁移创建点对点反馈表,并给正式租户 `000000` 幂等预置总裁、财务、人力、审计、运营五个处理角色;人员仍须由管理员按职责分配,业务匿名不抹除内部审计身份。公司文件迁移创建异步提炼表并给消息增加可空 `attachment_id`,不迁移或公开已有文件;紧随其后的多岗位解读迁移只在附件表增加生成状态、经引用校验的结构化结果和规则版本,并建立异步队列索引,不回填旧文件、不改变文件 READY 或消息发布状态。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。Agent 迁移只新增最小运行审计表,不保存问题、答案、密钥或模型推理。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。
|
||||
|
||||
## 迁移后只读核验
|
||||
|
||||
|
||||
@@ -584,6 +584,7 @@ contains backend/script/sql/update/aihr_20260714_brd_sop_seed_mysql8.sql "sop_da
|
||||
contains backend/script/sql/update/aihr_20260714_prompt_template_seed_mysql8.sql "summary_card"
|
||||
contains backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql "aihr_knowledge_gap"
|
||||
contains backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql "aihr_knowledge_answer_feedback"
|
||||
contains backend/script/sql/update/aihr_20260730_formal_knowledge_source_governance_mysql8.sql "aihr_knowledge_source_governance"
|
||||
contains backend/script/sql/aihr_practice_mysql8.sql "aihr_prompt_template"
|
||||
contains backend/script/sql/aihr_practice_mysql8.sql "summary_card"
|
||||
contains frontend/src/views/train/practice.vue "训练前预习卡"
|
||||
|
||||
@@ -219,6 +219,7 @@ FROM (
|
||||
UNION ALL SELECT 'aihr_knowledge_query_log'
|
||||
UNION ALL SELECT 'aihr_knowledge_conversation'
|
||||
UNION ALL SELECT 'aihr_knowledge_admin_audit'
|
||||
UNION ALL SELECT 'aihr_knowledge_source_governance'
|
||||
UNION ALL SELECT 'aihr_agent_run'
|
||||
UNION ALL SELECT 'aihr_learning_question'
|
||||
UNION ALL SELECT 'aihr_practice_question_feedback'
|
||||
|
||||
@@ -21,6 +21,7 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry
|
||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/ry_workflow.sql"
|
||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_knowledge_mysql8.sql"
|
||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260729_media_reprocess_mysql8.sql"
|
||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260730_formal_knowledge_source_governance_mysql8.sql"
|
||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_personal_knowledge_mysql8.sql"
|
||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_model_mysql8.sql"
|
||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260725_deepseek_v4_model_mysql8.sql"
|
||||
|
||||
@@ -448,6 +448,17 @@ test "$knowledge_category_code_index" = '0|tenant_id,knowledge_id,code|0'
|
||||
knowledge_category_collation="$(mysql "$DB" -N -B -e "SELECT table_collation FROM information_schema.tables WHERE table_schema='$DB' AND table_name='aihr_knowledge_category';")"
|
||||
test "$knowledge_category_collation" = 'utf8mb4_general_ci'
|
||||
|
||||
formal_source_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260730_formal_knowledge_source_governance_mysql8.sql"
|
||||
test -f "$formal_source_migration"
|
||||
mysql "$DB" < "$formal_source_migration"
|
||||
mysql "$DB" < "$formal_source_migration"
|
||||
formal_source_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_knowledge_source_governance' AND column_name IN ('tenant_id','source_id','attachment_id','source_doc_id','authority_type','lifecycle_status','source_version','effective_date','expires_date','applicability_scope','content_sha256','approval_basis','approved_by','approved_time');")"
|
||||
test "$formal_source_columns" = 'tenant_id,source_id,attachment_id,source_doc_id,authority_type,lifecycle_status,source_version,effective_date,expires_date,applicability_scope,content_sha256,approval_basis,approved_by,approved_time'
|
||||
formal_source_attachment_unique="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_knowledge_source_governance' AND index_name='uk_aihr_knowledge_source_attachment';")"
|
||||
test "$formal_source_attachment_unique" = '0|tenant_id,attachment_id'
|
||||
formal_source_collation="$(mysql "$DB" -N -B -e "SELECT table_collation FROM information_schema.tables WHERE table_schema='$DB' AND table_name='aihr_knowledge_source_governance';")"
|
||||
test "$formal_source_collation" = 'utf8mb4_general_ci'
|
||||
|
||||
broadcast_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260722_broadcast_mysql8.sql"
|
||||
test -f "$broadcast_migration"
|
||||
mysql "$DB" < "$broadcast_migration"
|
||||
|
||||
Reference in New Issue
Block a user