feat: complete governed knowledge lifecycle

This commit is contained in:
key
2026-08-03 14:34:54 +08:00
parent a275f98fad
commit df77998e0c
41 changed files with 4921 additions and 225 deletions
@@ -20,8 +20,8 @@ public class AihrKnowledgeIndexOutboxService {
private final JdbcTemplate jdbcTemplate;
private final AihrSopSeedService sopSeedService;
@Value("${aihr.qdrant.collection:${AIHR_QDRANT_COLLECTION:aihr_knowledge}}")
private String productionCollection = "aihr_knowledge";
@Value("${aihr.qdrant.governed-collection:${AIHR_QDRANT_GOVERNED_COLLECTION:aihr_knowledge_governed_v1}}")
private String productionCollection = AihrKnowledgeRolloutService.DEFAULT_GOVERNED_COLLECTION;
@Value("${aihr.knowledge.index-outbox-max-attempts:8}")
private int maxAttempts = 8;
@@ -36,34 +36,47 @@ public class AihrKnowledgeIndexOutboxService {
""");
jdbcTemplate.update("""
insert into aihr_index_outbox
(tenant_id, asset_id, version_id, operation, target_collection, payload_json, status,
(tenant_id, asset_id, version_id, operation, target_collection, index_generation,
payload_json, status,
attempt_count, next_attempt_time, create_time, update_time)
select a.tenant_id, a.id, a.current_version_id,
select a.tenant_id, a.id, coalesce(a.published_version_id, a.current_version_id),
case when a.lifecycle_status = 'PUBLISHED' then 'UPSERT' else 'DELETE' end,
?, json_object('docId', a.doc_id), 'PENDING', 0, now(), now(), now()
coalesce(scope.governed_collection, ?), coalesce(scope.active_generation, 1),
json_object('docId', a.doc_id, 'assetId', a.id,
'versionId', coalesce(a.published_version_id, a.current_version_id),
'generation', coalesce(scope.active_generation, 1)),
'PENDING', 0, now(), now(), now()
from aihr_data_asset a
left join aihr_knowledge_rollout_scope scope
on scope.tenant_id = a.tenant_id and scope.knowledge_id = a.knowledge_id
where a.lifecycle_status in ('PUBLISHED', 'DEPRECATED')
and a.index_status in ('PENDING', 'FAILED')
and not exists (
select 1 from aihr_index_outbox active_job
where active_job.tenant_id = a.tenant_id and active_job.asset_id = a.id
and active_job.version_id = a.current_version_id
and active_job.version_id = coalesce(a.published_version_id, a.current_version_id)
and active_job.status in ('PENDING', 'PROCESSING', 'FAILED', 'DEAD_LETTER')
)
""", productionCollection);
List<OutboxRow> rows = jdbcTemplate.query("""
select o.id, o.tenant_id, o.asset_id, o.version_id, o.operation,
o.target_collection, o.index_generation,
o.attempt_count, a.knowledge_id, a.doc_id, a.lifecycle_status,
a.current_version_id
coalesce(a.published_version_id, a.current_version_id),
coalesce(scope.active_generation, 1) active_generation
from aihr_index_outbox o
join aihr_data_asset a on a.tenant_id = o.tenant_id and a.id = o.asset_id
left join aihr_knowledge_rollout_scope scope
on scope.tenant_id = a.tenant_id and scope.knowledge_id = a.knowledge_id
where o.status in ('PENDING', 'FAILED') and o.next_attempt_time <= now()
order by o.id
limit 20
""", (rs, rowNum) -> new OutboxRow(rs.getLong("id"), rs.getString("tenant_id"),
rs.getLong("asset_id"), rs.getLong("version_id"), rs.getString("operation"),
rs.getInt("attempt_count"), rs.getLong("knowledge_id"), rs.getString("doc_id"),
rs.getString("lifecycle_status"), rs.getLong("current_version_id")));
rs.getString("target_collection"), rs.getLong("index_generation"), rs.getInt("attempt_count"),
rs.getLong("knowledge_id"), rs.getString("doc_id"),
rs.getString("lifecycle_status"), rs.getLong("current_version_id"),
rs.getLong("active_generation")));
rows.forEach(this::claimAndProcess);
} catch (RuntimeException ex) {
// The migration is additive. A pre-migration process must continue serving non-knowledge APIs.
@@ -91,10 +104,12 @@ public class AihrKnowledgeIndexOutboxService {
try {
Integer vectors = TenantHelper.dynamic(row.tenantId(), () -> {
if ("DELETE".equals(row.operation())) {
sopSeedService.deleteVectorDocument(row.knowledgeId(), row.docId());
sopSeedService.deleteVectorDocument(row.knowledgeId(), row.docId(),
row.collection(), row.generation());
return null;
}
return sopSeedService.vectorizePublishedDocument(row.knowledgeId(), row.docId());
return sopSeedService.vectorizePublishedDocument(row.knowledgeId(), row.docId(),
row.versionId(), row.collection(), row.generation());
});
jdbcTemplate.update("""
update aihr_index_outbox
@@ -104,9 +119,53 @@ public class AihrKnowledgeIndexOutboxService {
jdbcTemplate.update("""
update aihr_data_asset
set index_status = ?, update_time = now()
where tenant_id = ? and id = ? and current_version_id = ?
where tenant_id = ? and id = ?
and coalesce(published_version_id, current_version_id) = ?
""", "DELETE".equals(row.operation()) || vectors == null || vectors == 0 ? "NOT_REQUIRED" : "READY",
row.tenantId(), row.assetId(), row.versionId());
if ("UPSERT".equals(row.operation()) && vectors != null && vectors > 0) {
jdbcTemplate.update("""
insert into aihr_knowledge_generation
(tenant_id, knowledge_id, generation, collection_name, status,
version_count, point_count, create_time, ready_time, activated_time)
values (?, ?, ?, ?, ?, 0, 0, now(), ?, ?)
on duplicate key update collection_name = values(collection_name),
status = case when status = 'FAILED' then 'BUILDING' else status end,
ready_time = coalesce(ready_time, values(ready_time)),
activated_time = coalesce(activated_time, values(activated_time)), last_error = null
""", row.tenantId(), row.knowledgeId(), row.generation(), row.collection(),
row.generation() == row.activeGeneration() ? "ACTIVE" : "BUILDING",
row.generation() == row.activeGeneration() ? java.time.LocalDateTime.now() : null,
row.generation() == row.activeGeneration() ? java.time.LocalDateTime.now() : null);
jdbcTemplate.update("""
insert into aihr_generation_version
(tenant_id, knowledge_id, generation, asset_id, version_id, content_sha256,
point_count, status, create_time)
select a.tenant_id, a.knowledge_id, ?, a.id, ?, v.content_sha256, ?, 'INCLUDED', now()
from aihr_data_asset a
join aihr_data_version v on v.tenant_id = a.tenant_id and v.id = ?
where a.tenant_id = ? and a.id = ?
on duplicate key update version_id = values(version_id),
content_sha256 = values(content_sha256), point_count = values(point_count), status = 'INCLUDED'
""", row.generation(), row.versionId(), vectors, row.versionId(),
row.tenantId(), row.assetId());
jdbcTemplate.update("""
update aihr_knowledge_generation generation_row
set version_count = (select count(*) from aihr_generation_version member
where member.tenant_id = generation_row.tenant_id
and member.knowledge_id = generation_row.knowledge_id
and member.generation = generation_row.generation
and member.status = 'INCLUDED'),
point_count = (select coalesce(sum(member.point_count), 0)
from aihr_generation_version member
where member.tenant_id = generation_row.tenant_id
and member.knowledge_id = generation_row.knowledge_id
and member.generation = generation_row.generation
and member.status = 'INCLUDED')
where generation_row.tenant_id = ? and generation_row.knowledge_id = ?
and generation_row.generation = ?
""", row.tenantId(), row.knowledgeId(), row.generation());
}
} catch (RuntimeException ex) {
String message = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage();
int attempted = row.attemptCount() + 1;
@@ -128,7 +187,8 @@ public class AihrKnowledgeIndexOutboxService {
}
jdbcTemplate.update("""
update aihr_data_asset set index_status = 'FAILED', update_time = now()
where tenant_id = ? and id = ? and current_version_id = ?
where tenant_id = ? and id = ?
and coalesce(published_version_id, current_version_id) = ?
""", row.tenantId(), row.assetId(), row.versionId());
if ("DEAD_LETTER".equals(failureStatus)) {
log.error("knowledge index outbox dead-lettered id={} operation={} attempts={} reason={}",
@@ -191,7 +251,8 @@ public class AihrKnowledgeIndexOutboxService {
}
private record OutboxRow(long id, String tenantId, long assetId, long versionId, String operation,
int attemptCount, long knowledgeId, String docId, String lifecycleStatus,
long currentVersionId) {
String collection, long generation, int attemptCount, long knowledgeId,
String docId, String lifecycleStatus,
long currentVersionId, long activeGeneration) {
}
}
@@ -21,6 +21,8 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
@Service
@RequiredArgsConstructor
@@ -39,19 +41,39 @@ public class AihrKnowledgeLegacyMigrationService {
}
public MigrationPreview previewBatch(String tenantId, long afterAttachmentId, int limit) {
return previewBatch(tenantId, null, afterAttachmentId, limit);
}
public MigrationPreview previewBatch(String tenantId, Long knowledgeId, long afterAttachmentId, int limit) {
return previewBatch(tenantId, knowledgeId, afterAttachmentId, limit, "ALL");
}
public MigrationPreview previewBatch(String tenantId, Long knowledgeId, long afterAttachmentId, int limit,
String riskScope) {
int boundedLimit = Math.max(1, Math.min(limit, 100));
long cursor = Math.max(0L, afterAttachmentId);
List<LegacyAttachment> rows = findCandidates(tenantId, cursor, boundedLimit);
String scope = normalizeRiskScope(riskScope);
List<LegacyAttachment> rows = findCandidates(tenantId, knowledgeId, cursor, boundedLimit, scope);
long nextCursor = rows.isEmpty() ? cursor : rows.get(rows.size() - 1).attachmentId();
int sourceUnavailable = (int) rows.stream().filter(row -> row.ossId() == null).count();
return new MigrationPreview(rows.size(), rows.size() - sourceUnavailable, sourceUnavailable,
nextCursor, hasMore(tenantId, nextCursor));
nextCursor, hasMore(tenantId, knowledgeId, nextCursor, scope));
}
public MigrationResult stageBatch(String tenantId, long afterAttachmentId, int limit) {
return stageBatch(tenantId, null, afterAttachmentId, limit);
}
public MigrationResult stageBatch(String tenantId, Long knowledgeId, long afterAttachmentId, int limit) {
return stageBatch(tenantId, knowledgeId, afterAttachmentId, limit, "ALL");
}
public MigrationResult stageBatch(String tenantId, Long knowledgeId, long afterAttachmentId, int limit,
String riskScope) {
int boundedLimit = Math.max(1, Math.min(limit, 100));
long cursor = Math.max(0L, afterAttachmentId);
List<LegacyAttachment> rows = findCandidates(tenantId, cursor, boundedLimit);
String scope = normalizeRiskScope(riskScope);
List<LegacyAttachment> rows = findCandidates(tenantId, knowledgeId, cursor, boundedLimit, scope);
List<Long> stagedAssetIds = new ArrayList<>();
List<Long> failedAttachmentIds = new ArrayList<>();
@@ -90,20 +112,23 @@ public class AihrKnowledgeLegacyMigrationService {
}
}
}
boolean moreAvailable = hasMore(tenantId, nextCursor);
boolean moreAvailable = hasMore(tenantId, knowledgeId, nextCursor, scope);
return new MigrationResult(rows.size(), stagedAssetIds.size(), reparsed, quarantined,
List.copyOf(stagedAssetIds), List.copyOf(failedAttachmentIds), nextCursor, moreAvailable);
}
private List<LegacyAttachment> findCandidates(String tenantId, long cursor, int limit) {
private List<LegacyAttachment> findCandidates(String tenantId, Long knowledgeId, long cursor, int limit,
String riskScope) {
return jdbcTemplate.query("""
select a.id, a.knowledge_id, a.doc_id,
case when o.oss_id is null then null else a.oss_id end verified_oss_id,
coalesce(a.name, a.doc_id) source_name, a.create_by
from aihr_knowledge_attach a
left join sys_oss o on o.oss_id = a.oss_id and binary o.tenant_id = binary a.tenant_id
where a.tenant_id = ? and a.id > ? and a.status = 2
where a.tenant_id = ? and (? is null or a.knowledge_id = ?) and a.id > ? and a.status = 2
and nullif(trim(a.doc_id), '') is not null
and (? = 'ALL' or (? = 'LOW_RISK' and o.oss_id is not null)
or (? = 'QUARANTINE_FIRST' and o.oss_id is null))
and not exists (
select 1 from aihr_data_asset governed
where governed.tenant_id = a.tenant_id and governed.knowledge_id = a.knowledge_id
@@ -113,7 +138,8 @@ public class AihrKnowledgeLegacyMigrationService {
limit ?
""", (rs, rowNum) -> new LegacyAttachment(rs.getLong("id"), rs.getLong("knowledge_id"),
rs.getString("doc_id"), rs.getObject("verified_oss_id", Long.class), rs.getString("source_name"),
rs.getObject("create_by", Long.class)), tenantId, cursor, limit);
rs.getObject("create_by", Long.class)), tenantId, knowledgeId, knowledgeId, cursor,
riskScope, riskScope, riskScope, limit);
}
private UploadResponse restageFromRaw(String tenantId, LegacyAttachment row) throws IOException {
@@ -130,7 +156,7 @@ public class AihrKnowledgeLegacyMigrationService {
copyBounded(input, output, MAX_RAW_BYTES);
}
return TenantHelper.dynamic(tenantId,
() -> sopSeedService.reprocessStagedAttachment(row.attachmentId(), staged));
() -> sopSeedService.stageLegacyAttachmentCandidate(row.attachmentId(), staged));
} finally {
try {
Files.deleteIfExists(staged);
@@ -158,20 +184,32 @@ public class AihrKnowledgeLegacyMigrationService {
return assetIds.isEmpty() ? null : assetIds.get(0);
}
private boolean hasMore(String tenantId, long cursor) {
private boolean hasMore(String tenantId, Long knowledgeId, long cursor, String riskScope) {
Integer count = jdbcTemplate.queryForObject("""
select count(*) from aihr_knowledge_attach a
where a.tenant_id = ? and a.id > ? and a.status = 2
left join sys_oss o on o.oss_id = a.oss_id and binary o.tenant_id = binary a.tenant_id
where a.tenant_id = ? and (? is null or a.knowledge_id = ?) and a.id > ? and a.status = 2
and nullif(trim(a.doc_id), '') is not null
and (? = 'ALL' or (? = 'LOW_RISK' and o.oss_id is not null)
or (? = 'QUARANTINE_FIRST' and o.oss_id is null))
and not exists (
select 1 from aihr_data_asset governed
where governed.tenant_id = a.tenant_id and governed.knowledge_id = a.knowledge_id
and governed.doc_id = a.doc_id
)
""", Integer.class, tenantId, cursor);
""", Integer.class, tenantId, knowledgeId, knowledgeId, cursor,
riskScope, riskScope, riskScope);
return count != null && count > 0;
}
private static String normalizeRiskScope(String value) {
String normalized = value == null ? "ALL" : value.trim().toUpperCase(Locale.ROOT);
if (!Set.of("ALL", "LOW_RISK", "QUARANTINE_FIRST").contains(normalized)) {
throw new IllegalArgumentException("Unknown migration risk scope");
}
return normalized;
}
private static void copyBounded(InputStream input, OutputStream output, long maxBytes) throws IOException {
byte[] buffer = new byte[8192];
long total = 0;
@@ -42,8 +42,8 @@ public class AihrKnowledgeLifecycleService {
private final AihrKnowledgeQualityGateService qualityGateService;
private final AihrKnowledgeClaimService claimService;
@Value("${aihr.qdrant.collection:${AIHR_QDRANT_COLLECTION:aihr_knowledge}}")
private String productionCollection = "aihr_knowledge";
@Value("${aihr.qdrant.governed-collection:${AIHR_QDRANT_GOVERNED_COLLECTION:aihr_knowledge_governed_v1}}")
private String productionCollection = AihrKnowledgeRolloutService.DEFAULT_GOVERNED_COLLECTION;
@Transactional
public StagedAsset stageParsedDocument(StageCommand command) {
@@ -154,12 +154,22 @@ public class AihrKnowledgeLifecycleService {
semantic_detector_version = ? where tenant_id = ? and id = ?
""", AihrKnowledgeSemanticAnalysisService.DETECTOR_VERSION, command.tenantId(), versionId);
}
jdbcTemplate.update("""
update aihr_data_version
set version_status = ?
where tenant_id = ? and id = ? and immutable_status = 'SEALED'
""", assessment.status().name(), command.tenantId(), versionId);
jdbcTemplate.update("""
update aihr_data_asset
set current_version_id = ?, lifecycle_status = ?, index_status = 'NOT_REQUIRED',
trust_level = 'UNTRUSTED', content_sha256 = ?, update_time = now()
set candidate_version_id = ?,
current_version_id = case when published_version_id is null then ? else current_version_id end,
lifecycle_status = case when published_version_id is null then ? else lifecycle_status end,
index_status = case when published_version_id is null then 'NOT_REQUIRED' else index_status end,
trust_level = case when published_version_id is null then 'UNTRUSTED' else trust_level end,
content_sha256 = case when published_version_id is null then ? else content_sha256 end,
update_time = now()
where tenant_id = ? and id = ?
""", versionId, assessment.status().name(), contentHash, command.tenantId(), assetId);
""", versionId, versionId, assessment.status().name(), contentHash, command.tenantId(), assetId);
List<String> reasonCodes = new ArrayList<>(assessment.findings().stream()
.map(finding -> finding.reasonCode().name()).toList());
dynamicReasons.stream().map(Enum::name).forEach(reasonCodes::add);
@@ -191,12 +201,22 @@ public class AihrKnowledgeLifecycleService {
String.valueOf(versionId), "PARSE_FAILED", command.extractionQuality().extractorName(),
command.extractionQuality().extractorVersion());
}
jdbcTemplate.update("""
update aihr_data_version
set version_status = 'QUARANTINED'
where tenant_id = ? and id = ? and immutable_status = 'SEALED'
""", command.tenantId(), versionId);
jdbcTemplate.update("""
update aihr_data_asset
set current_version_id = ?, lifecycle_status = 'QUARANTINED', index_status = 'NOT_REQUIRED',
trust_level = 'UNTRUSTED', content_sha256 = ?, update_time = now()
set candidate_version_id = ?,
current_version_id = case when published_version_id is null then ? else current_version_id end,
lifecycle_status = case when published_version_id is null then 'QUARANTINED' else lifecycle_status end,
index_status = case when published_version_id is null then 'NOT_REQUIRED' else index_status end,
trust_level = case when published_version_id is null then 'UNTRUSTED' else trust_level end,
content_sha256 = case when published_version_id is null then ? else content_sha256 end,
update_time = now()
where tenant_id = ? and id = ?
""", versionId, rawHash, command.tenantId(), assetId);
""", versionId, versionId, rawHash, command.tenantId(), assetId);
return new StagedAsset(assetId, versionId, versionNo, Status.QUARANTINED.name(),
List.of(command.reasonCode().name()), 0);
}
@@ -212,7 +232,8 @@ public class AihrKnowledgeLifecycleService {
a.source_authority, a.source_version, a.data_class, a.lifecycle_status, a.index_status,
a.applicable_region, a.applicable_project, a.applicable_role,
a.effective_from, a.effective_to,
a.current_version_id, v.semantic_analysis_status, v.semantic_last_error,
coalesce(a.candidate_version_id, a.current_version_id) as current_version_id,
v.semantic_analysis_status, v.semantic_last_error, v.version_status,
v.semantic_retry_count, a.update_time,
(select count(*) from aihr_quality_issue q
where q.tenant_id = a.tenant_id and q.asset_id = a.id and q.status = 'OPEN') issue_count,
@@ -220,8 +241,11 @@ public class AihrKnowledgeLifecycleService {
where q.tenant_id = a.tenant_id and q.asset_id = a.id and q.status = 'OPEN'
and q.gate_type = 'HARD') blocking_issue_count
from aihr_data_asset a
join aihr_data_version v on v.tenant_id = a.tenant_id and v.id = a.current_version_id
where a.tenant_id = ? and a.lifecycle_status = ?
join aihr_data_version v on v.tenant_id = a.tenant_id
and v.id = coalesce(a.candidate_version_id, a.current_version_id)
where a.tenant_id = ?
and ((? = 'PUBLISHED' and a.lifecycle_status = 'PUBLISHED')
or (? <> 'PUBLISHED' and v.version_status = ?))
order by a.update_time asc, a.id asc
limit ?
""", (rs, rowNum) -> new AssetSummary(
@@ -235,7 +259,7 @@ public class AihrKnowledgeLifecycleService {
rs.getObject("current_version_id", Long.class), rs.getString("semantic_analysis_status"),
rs.getString("semantic_last_error"), rs.getInt("semantic_retry_count"), rs.getInt("issue_count"),
rs.getInt("blocking_issue_count"), rs.getObject("update_time", LocalDateTime.class)),
tenantId, normalizedStatus, boundedLimit);
tenantId, normalizedStatus, normalizedStatus, normalizedStatus, boundedLimit);
}
public List<QualityIssue> issues(String tenantId, long assetId) {
@@ -256,7 +280,8 @@ public class AihrKnowledgeLifecycleService {
select v.id, v.privacy_status, v.redaction_policy_version, v.redaction_summary_json,
v.redacted_source_name, v.redacted_content
from aihr_data_asset a
join aihr_data_version v on v.tenant_id = a.tenant_id and v.id = a.current_version_id
join aihr_data_version v on v.tenant_id = a.tenant_id
and v.id = coalesce(a.candidate_version_id, a.current_version_id)
where a.tenant_id = ? and a.id = ?
limit 1
""", (rs, rowNum) -> new PrivacyPreview(
@@ -276,15 +301,22 @@ public class AihrKnowledgeLifecycleService {
}
AssetRow asset = lockAsset(tenantId, assetId);
Status current = Status.valueOf(asset.lifecycleStatus());
if (!AihrKnowledgeLifecycle.canApprove(current)) {
boolean candidateReview = asset.candidateVersionId() != null
&& versionStatus(tenantId, asset.versionId()).map(status ->
Set.of("REVIEW_PENDING", "QUARANTINED", "APPROVED").contains(status)).orElse(false);
if (!AihrKnowledgeLifecycle.canApprove(current) && !candidateReview) {
throw new ServiceException("Asset is not awaiting review", HttpStatus.CONFLICT);
}
long versionId = asset.versionId();
if (command.expectedVersionId() != null && command.expectedVersionId() != versionId) {
throw new ServiceException("Candidate version changed; reload the review package", HttpStatus.CONFLICT);
}
VersionGate versionGate = jdbcTemplate.queryForObject("""
select semantic_analysis_status, privacy_status,
case when redacted_content is null or redaction_policy_version is null then 0 else 1 end privacy_ready
from aihr_data_version where tenant_id = ? and id = ?
""", (rs, rowNum) -> new VersionGate(rs.getString("semantic_analysis_status"),
rs.getString("privacy_status"), rs.getBoolean("privacy_ready")), tenantId, asset.versionId());
rs.getString("privacy_status"), rs.getBoolean("privacy_ready")), tenantId, versionId);
if (versionGate == null || !versionGate.privacyReady()
|| !Set.of("CLEAN", "REDACTED").contains(versionGate.privacyStatus())) {
throw new ServiceException("A current privacy derivative and residual scan are required before approval",
@@ -294,19 +326,19 @@ public class AihrKnowledgeLifecycleService {
throw new ServiceException("Semantic duplicate analysis must complete before approval",
HttpStatus.CONFLICT);
}
claimService.requireResolvedConflicts(tenantId, assetId, asset.versionId());
acceptSoftFindings(tenantId, assetId, asset.versionId(), reviewerId, command.acceptedReasonCodes());
claimService.requireResolvedConflicts(tenantId, assetId, versionId);
acceptSoftFindings(tenantId, assetId, versionId, reviewerId, command.acceptedReasonCodes());
Integer hardOpen = jdbcTemplate.queryForObject("""
select count(*) from aihr_quality_issue
where tenant_id = ? and asset_id = ? and version_id = ? and status = 'OPEN' and gate_type = 'HARD'
""", Integer.class, tenantId, assetId, asset.versionId());
""", Integer.class, tenantId, assetId, versionId);
if (hardOpen != null && hardOpen > 0) {
throw new ServiceException("Blocking quality findings must be resolved before approval", HttpStatus.CONFLICT);
}
Integer softOpen = jdbcTemplate.queryForObject("""
select count(*) from aihr_quality_issue
where tenant_id = ? and asset_id = ? and version_id = ? and status = 'OPEN' and gate_type = 'SOFT'
""", Integer.class, tenantId, assetId, asset.versionId());
""", Integer.class, tenantId, assetId, versionId);
if (softOpen != null && softOpen > 0) {
throw new ServiceException("Every soft quality finding must be acknowledged before approval", HttpStatus.CONFLICT);
}
@@ -325,7 +357,7 @@ public class AihrKnowledgeLifecycleService {
"Normative knowledge requires an approved authority type and source version", HttpStatus.BAD_REQUEST);
}
if (usage == UsageType.NORMATIVE_KNOWLEDGE
&& hasQualityReason(tenantId, assetId, asset.versionId(), ReasonCode.VERSION_CONFLICT)
&& hasQualityReason(tenantId, assetId, versionId, ReasonCode.VERSION_CONFLICT)
&& command.supersedesAssetId() == null) {
throw new ServiceException("A conflicting normative source must explicitly supersede the old asset",
HttpStatus.CONFLICT);
@@ -344,13 +376,12 @@ public class AihrKnowledgeLifecycleService {
"Superseded by asset " + assetId + ": " + command.reason().trim());
}
removePublishedFragments(tenantId, assetId);
List<ChunkRow> chunks = jdbcTemplate.query("""
select id, chunk_index, content, locator_json from aihr_chunk_revision
where tenant_id = ? and asset_id = ? and version_id = ? order by chunk_index
""", (rs, rowNum) -> new ChunkRow(rs.getLong("id"), rs.getInt("chunk_index"), rs.getString("content"),
rs.getString("locator_json")),
tenantId, assetId, asset.versionId());
tenantId, assetId, versionId);
if (chunks.isEmpty()) {
throw new ServiceException("Approved version has no chunks", HttpStatus.CONFLICT);
}
@@ -374,7 +405,7 @@ public class AihrKnowledgeLifecycleService {
""", Statement.RETURN_GENERATED_KEYS);
statement.setString(1, tenantId);
statement.setLong(2, assetId);
statement.setLong(3, asset.versionId());
statement.setLong(3, versionId);
statement.setString(4, command.reason().trim());
statement.setString(5, json(new Snapshot(asset.lifecycleStatus(), asset.dataClass(), asset.sourceVersion())));
statement.setString(6, json(new Snapshot("PUBLISHED", usage.name(), command.sourceVersion())));
@@ -388,15 +419,31 @@ public class AihrKnowledgeLifecycleService {
values (?, ?, 'production', ?, 'ACTIVE', ?, now())
on duplicate key update usage_type = values(usage_type), status = 'ACTIVE',
approved_review_id = values(approved_review_id)
""", tenantId, asset.versionId(), usage.name(), reviewId);
enqueueIndex(tenantId, assetId, asset.versionId(), "UPSERT", asset.docId());
""", tenantId, versionId, usage.name(), reviewId);
if (asset.publishedVersionId() != null && asset.publishedVersionId() != versionId) {
jdbcTemplate.update("""
update aihr_dataset_membership set status = 'DISABLED'
where tenant_id = ? and version_id = ? and dataset_code = 'production'
""", tenantId, asset.publishedVersionId());
jdbcTemplate.update("""
update aihr_data_version set version_status = 'SUPERSEDED'
where tenant_id = ? and id = ? and version_status = 'PUBLISHED'
""", tenantId, asset.publishedVersionId());
}
jdbcTemplate.update("""
update aihr_data_version set version_status = 'PUBLISHED'
where tenant_id = ? and id = ? and immutable_status = 'SEALED'
""", tenantId, versionId);
enqueueIndex(tenantId, assetId, versionId, "UPSERT", asset.docId());
jdbcTemplate.update("""
update aihr_data_asset
set lifecycle_status = 'PUBLISHED', index_status = 'PENDING', data_class = ?, update_time = now()
set lifecycle_status = 'PUBLISHED', index_status = 'PENDING', data_class = ?,
published_version_id = ?, candidate_version_id = null, current_version_id = ?,
published_time = coalesce(published_time, now()), update_time = now()
where tenant_id = ? and id = ?
""", usage.name(), tenantId, assetId);
claimService.markPublished(tenantId, asset.versionId());
return new PublishedAsset(assetId, asset.versionId(), "PUBLISHED", "PENDING", chunks.size(), reviewId);
""", usage.name(), versionId, versionId, tenantId, assetId);
claimService.markPublished(tenantId, versionId);
return new PublishedAsset(assetId, versionId, "PUBLISHED", "PENDING", chunks.size(), reviewId);
}
@Transactional
@@ -408,13 +455,28 @@ public class AihrKnowledgeLifecycleService {
if (!AihrKnowledgeLifecycle.canWithdraw(Status.valueOf(asset.lifecycleStatus()))) {
throw new ServiceException("Only approved or published assets can be withdrawn", HttpStatus.CONFLICT);
}
int fragments = removePublishedFragments(tenantId, assetId);
long versionId = asset.publishedVersionId() == null ? asset.versionId() : asset.publishedVersionId();
Integer fragments = jdbcTemplate.queryForObject("""
select count(*) from aihr_chunk_revision
where tenant_id = ? and asset_id = ? and version_id = ? and published_fragment_id is not null
""", Integer.class, tenantId, assetId, versionId);
jdbcTemplate.update("update aihr_dataset_membership set status = 'DISABLED' where tenant_id = ? and version_id = ?",
tenantId, asset.versionId());
enqueueIndex(tenantId, assetId, asset.versionId(), "DELETE", asset.docId());
tenantId, versionId);
jdbcTemplate.update("""
update aihr_data_version set version_status = 'WITHDRAWN'
where tenant_id = ? and id = ? and version_status in ('PUBLISHED','APPROVED','REVIEW_PENDING','QUARANTINED')
""", tenantId, versionId);
if (asset.candidateVersionId() != null && asset.candidateVersionId() != versionId) {
jdbcTemplate.update("""
update aihr_data_version set version_status = 'WITHDRAWN'
where tenant_id = ? and id = ? and version_status in ('DRAFT','PROCESSING','REVIEW_PENDING','QUARANTINED','APPROVED')
""", tenantId, asset.candidateVersionId());
}
enqueueIndex(tenantId, assetId, versionId, "DELETE", asset.docId());
jdbcTemplate.update("""
update aihr_data_asset
set lifecycle_status = 'DEPRECATED', index_status = 'PENDING', trust_level = 'WITHDRAWN', update_time = now()
set lifecycle_status = 'DEPRECATED', index_status = 'PENDING', trust_level = 'WITHDRAWN',
published_version_id = null, candidate_version_id = null, update_time = now()
where tenant_id = ? and id = ?
""", tenantId, assetId);
claimService.markDeprecated(tenantId, assetId);
@@ -422,8 +484,8 @@ public class AihrKnowledgeLifecycleService {
insert into aihr_review_decision
(tenant_id, asset_id, version_id, decision, reason, reviewer_id, reviewer_type, reviewed_time)
values (?, ?, ?, 'WITHDRAW', ?, ?, 'HUMAN', now())
""", tenantId, assetId, asset.versionId(), reason.trim(), reviewerId);
return new PublishedAsset(assetId, asset.versionId(), "DEPRECATED", "PENDING", fragments, null);
""", tenantId, assetId, versionId, reason.trim(), reviewerId);
return new PublishedAsset(assetId, versionId, "DEPRECATED", "PENDING", fragments == null ? 0 : fragments, null);
}
private long upsertAsset(StageCommand command, String contentHash) {
@@ -650,7 +712,9 @@ public class AihrKnowledgeLifecycleService {
List<DuplicateMatch> rows = jdbcTemplate.query("""
select a.id asset_id, v.id version_id
from aihr_data_asset a
join aihr_data_version v on v.tenant_id = a.tenant_id and v.id = a.current_version_id
join aihr_data_version v on v.tenant_id = a.tenant_id
and v.id = coalesce(a.candidate_version_id,
a.published_version_id, a.current_version_id)
where a.tenant_id = ? and a.id <> ? and v.content_sha256 = ?
order by case a.lifecycle_status when 'PUBLISHED' then 0 else 1 end, a.update_time desc
limit 1
@@ -711,7 +775,9 @@ public class AihrKnowledgeLifecycleService {
return jdbcTemplate.query("""
select a.id, v.id version_id, v.simhash64
from aihr_data_asset a
join aihr_data_version v on v.tenant_id = a.tenant_id and v.id = a.current_version_id
join aihr_data_version v on v.tenant_id = a.tenant_id
and v.id = coalesce(a.candidate_version_id,
a.published_version_id, a.current_version_id)
where a.tenant_id = ? and a.id <> ? and v.simhash64 is not null
order by a.update_time desc
limit 2000
@@ -756,13 +822,16 @@ public class AihrKnowledgeLifecycleService {
throw new ServiceException("Invalid superseded asset", HttpStatus.BAD_REQUEST);
}
List<AssetRow> rows = jdbcTemplate.query("""
select id, knowledge_id, doc_id, current_version_id, lifecycle_status, data_class,
select id, knowledge_id, doc_id, coalesce(candidate_version_id, current_version_id) as current_version_id,
published_version_id, candidate_version_id, lifecycle_status, data_class,
source_version, source_name
from aihr_data_asset
where tenant_id = ? and id = ? for update
""", (rs, rowNum) -> new AssetRow(rs.getLong("id"), rs.getLong("knowledge_id"), rs.getString("doc_id"),
rs.getLong("current_version_id"), rs.getString("lifecycle_status"), rs.getString("data_class"),
rs.getString("source_version"), rs.getString("source_name")), tenantId, supersededAssetId);
rs.getLong("current_version_id"), rs.getObject("published_version_id", Long.class),
rs.getObject("candidate_version_id", Long.class), rs.getString("lifecycle_status"),
rs.getString("data_class"), rs.getString("source_version"), rs.getString("source_name")),
tenantId, supersededAssetId);
if (rows.isEmpty() || !Set.of("PUBLISHED", "APPROVED").contains(rows.get(0).lifecycleStatus())) {
throw new ServiceException("Superseded asset is not currently published", HttpStatus.CONFLICT);
}
@@ -801,18 +870,115 @@ public class AihrKnowledgeLifecycleService {
private AssetRow lockAsset(String tenantId, long assetId) {
List<AssetRow> rows = jdbcTemplate.query("""
select id, knowledge_id, doc_id, current_version_id, lifecycle_status, data_class, source_version,
select id, knowledge_id, doc_id, coalesce(candidate_version_id, current_version_id) as current_version_id,
published_version_id, candidate_version_id, lifecycle_status, data_class, source_version,
source_name
from aihr_data_asset where tenant_id = ? and id = ? for update
""", (rs, rowNum) -> new AssetRow(rs.getLong("id"), rs.getLong("knowledge_id"), rs.getString("doc_id"),
rs.getLong("current_version_id"), rs.getString("lifecycle_status"), rs.getString("data_class"),
rs.getString("source_version"), rs.getString("source_name")), tenantId, assetId);
rs.getLong("current_version_id"), rs.getObject("published_version_id", Long.class),
rs.getObject("candidate_version_id", Long.class), rs.getString("lifecycle_status"),
rs.getString("data_class"), rs.getString("source_version"), rs.getString("source_name")), tenantId, assetId);
if (rows.isEmpty()) {
throw new ServiceException("Data asset not found", HttpStatus.NOT_FOUND);
}
return rows.get(0);
}
public List<VersionSummary> versions(String tenantId, long assetId) {
requireAsset(tenantId, assetId);
return jdbcTemplate.query("""
select v.id, v.version_no, v.version_status, v.revision_of_version_id, v.revision_reason,
v.content_sha256, v.parser_name, v.parser_version, v.cleaning_policy_version,
v.classification_policy_version, v.semantic_analysis_status, v.privacy_status,
v.create_time, a.published_version_id, a.candidate_version_id,
(select count(*) from aihr_chunk_revision c
where c.tenant_id = v.tenant_id and c.version_id = v.id) chunk_count,
(select count(*) from aihr_quality_issue q
where q.tenant_id = v.tenant_id and q.version_id = v.id and q.status = 'OPEN') open_issue_count
from aihr_data_version v
join aihr_data_asset a on a.tenant_id = v.tenant_id and a.id = v.asset_id
where v.tenant_id = ? and v.asset_id = ?
order by v.version_no desc, v.id desc
""", (rs, rowNum) -> {
long versionId = rs.getLong("id");
Long publishedVersionId = rs.getObject("published_version_id", Long.class);
Long candidateVersionId = rs.getObject("candidate_version_id", Long.class);
return new VersionSummary(versionId, rs.getInt("version_no"), rs.getString("version_status"),
rs.getObject("revision_of_version_id", Long.class), rs.getString("revision_reason"),
rs.getString("content_sha256"), rs.getString("parser_name"), rs.getString("parser_version"),
rs.getString("cleaning_policy_version"), rs.getString("classification_policy_version"),
rs.getString("semantic_analysis_status"), rs.getString("privacy_status"),
publishedVersionId != null && versionId == publishedVersionId,
candidateVersionId != null && versionId == candidateVersionId,
rs.getInt("chunk_count"), rs.getInt("open_issue_count"),
rs.getObject("create_time", LocalDateTime.class));
}, tenantId, assetId);
}
public VersionDetail versionDetail(String tenantId, long assetId, long versionId) {
List<VersionDetail> rows = jdbcTemplate.query("""
select v.id, v.version_no, v.version_status, v.revision_of_version_id, v.revision_reason,
v.content_sha256, v.redacted_source_name, v.redacted_content, v.structure_profile_json,
v.extraction_quality_json, v.redaction_summary_json, v.semantic_analysis_status,
v.semantic_embedding_model, v.semantic_embedding_dimension, v.privacy_status,
v.parser_name, v.parser_version, v.cleaning_policy_version,
v.classification_policy_version, v.create_time,
a.published_version_id, a.candidate_version_id
from aihr_data_version v
join aihr_data_asset a on a.tenant_id = v.tenant_id and a.id = v.asset_id
where v.tenant_id = ? and v.asset_id = ? and v.id = ?
limit 1
""", (rs, rowNum) -> {
long selectedVersionId = rs.getLong("id");
Long publishedVersionId = rs.getObject("published_version_id", Long.class);
Long candidateVersionId = rs.getObject("candidate_version_id", Long.class);
return new VersionDetail(selectedVersionId, rs.getInt("version_no"), rs.getString("version_status"),
rs.getObject("revision_of_version_id", Long.class), rs.getString("revision_reason"),
rs.getString("content_sha256"), rs.getString("redacted_source_name"),
rs.getString("redacted_content"), rs.getString("structure_profile_json"),
rs.getString("extraction_quality_json"), rs.getString("redaction_summary_json"),
rs.getString("semantic_analysis_status"), rs.getString("semantic_embedding_model"),
rs.getObject("semantic_embedding_dimension", Integer.class), rs.getString("privacy_status"),
rs.getString("parser_name"), rs.getString("parser_version"),
rs.getString("cleaning_policy_version"), rs.getString("classification_policy_version"),
publishedVersionId != null && selectedVersionId == publishedVersionId,
candidateVersionId != null && selectedVersionId == candidateVersionId,
List.of(), rs.getObject("create_time", LocalDateTime.class));
},
tenantId, assetId, versionId);
if (rows.isEmpty()) {
throw new ServiceException("Asset version does not exist", HttpStatus.NOT_FOUND);
}
return rows.get(0).withChunks(versionChunks(tenantId, versionId));
}
private List<VersionChunk> versionChunks(String tenantId, long versionId) {
return jdbcTemplate.query("""
select id, chunk_index, content, content_sha256, heading_path, context_prefix,
locator_json, published_fragment_id
from aihr_chunk_revision
where tenant_id = ? and version_id = ? order by chunk_index
""", (rs, rowNum) -> new VersionChunk(rs.getLong("id"), rs.getInt("chunk_index"),
rs.getString("content"), rs.getString("content_sha256"), rs.getString("heading_path"),
rs.getString("context_prefix"), rs.getString("locator_json"),
rs.getObject("published_fragment_id", Long.class)), tenantId, versionId);
}
private void requireAsset(String tenantId, long assetId) {
Integer count = jdbcTemplate.queryForObject(
"select count(*) from aihr_data_asset where tenant_id = ? and id = ?", Integer.class, tenantId, assetId);
if (count == null || count == 0) {
throw new ServiceException("Data asset not found", HttpStatus.NOT_FOUND);
}
}
private java.util.Optional<String> versionStatus(String tenantId, long versionId) {
List<String> statuses = jdbcTemplate.queryForList("""
select version_status from aihr_data_version where tenant_id = ? and id = ? limit 1
""", String.class, tenantId, versionId);
return statuses.stream().findFirst();
}
private void acceptSoftFindings(String tenantId, long assetId, long versionId, long reviewerId,
List<String> reasonCodes) {
Set<String> accepted = new LinkedHashSet<>(reasonCodes == null ? List.of() : reasonCodes);
@@ -844,22 +1010,6 @@ public class AihrKnowledgeLifecycleService {
command.supersedesAssetId(), tenantId, assetId);
}
private int removePublishedFragments(String tenantId, long assetId) {
List<Long> ids = jdbcTemplate.queryForList("""
select published_fragment_id from aihr_chunk_revision
where tenant_id = ? and asset_id = ? and published_fragment_id is not null
""", Long.class, tenantId, assetId);
for (Long fragmentId : ids) {
jdbcTemplate.update("delete from aihr_knowledge_fragment_locator where tenant_id = ? and fragment_id = ?",
tenantId, fragmentId);
jdbcTemplate.update("delete from aihr_knowledge_fragment where tenant_id = ? and id = ?", tenantId, fragmentId);
}
jdbcTemplate.update("""
update aihr_chunk_revision set published_fragment_id = null where tenant_id = ? and asset_id = ?
""", tenantId, assetId);
return ids.size();
}
private long insertPublishedFragment(String tenantId, long knowledgeId, String docId, long reviewerId,
ChunkRow chunk) {
KeyHolder keys = new GeneratedKeyHolder();
@@ -929,12 +1079,26 @@ public class AihrKnowledgeLifecycleService {
}
private void enqueueIndex(String tenantId, long assetId, long versionId, String operation, String docId) {
IndexTarget target = jdbcTemplate.queryForObject("""
select coalesce(scope.governed_collection, ?) collection_name,
coalesce(scope.active_generation, 1) index_generation
from aihr_data_asset asset
left join aihr_knowledge_rollout_scope scope
on scope.tenant_id = asset.tenant_id and scope.knowledge_id = asset.knowledge_id
where asset.tenant_id = ? and asset.id = ?
""", (rs, rowNum) -> new IndexTarget(rs.getString("collection_name"),
rs.getLong("index_generation")), productionCollection, tenantId, assetId);
if (target == null) {
throw new ServiceException("Knowledge index target does not exist", HttpStatus.NOT_FOUND);
}
jdbcTemplate.update("""
insert into aihr_index_outbox
(tenant_id, asset_id, version_id, operation, target_collection, payload_json, status,
(tenant_id, asset_id, version_id, operation, target_collection, index_generation,
payload_json, status,
attempt_count, next_attempt_time, create_time, update_time)
values (?, ?, ?, ?, ?, ?, 'PENDING', 0, now(), now(), now())
""", tenantId, assetId, versionId, operation, productionCollection, json(new IndexPayload(docId)));
values (?, ?, ?, ?, ?, ?, ?, 'PENDING', 0, now(), now(), now())
""", tenantId, assetId, versionId, operation, target.collection(), target.generation(),
json(new IndexPayload(docId, assetId, versionId, target.generation())));
}
private void addLineage(String tenantId, String fromType, String fromId, String toType, String toId,
@@ -994,7 +1158,7 @@ public class AihrKnowledgeLifecycleService {
}
}
static String sha256(String value) {
public static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
@@ -1058,7 +1222,14 @@ public class AihrKnowledgeLifecycleService {
public record ReviewCommand(String reason, String usageType, String sourceAuthority, String sourceVersion,
String applicableRegion, String applicableProject, String applicableRole,
LocalDate effectiveFrom, LocalDate effectiveTo, List<String> acceptedReasonCodes,
Long supersedesAssetId) {
Long supersedesAssetId, Long expectedVersionId) {
public ReviewCommand(String reason, String usageType, String sourceAuthority, String sourceVersion,
String applicableRegion, String applicableProject, String applicableRole,
LocalDate effectiveFrom, LocalDate effectiveTo, List<String> acceptedReasonCodes,
Long supersedesAssetId) {
this(reason, usageType, sourceAuthority, sourceVersion, applicableRegion, applicableProject,
applicableRole, effectiveFrom, effectiveTo, acceptedReasonCodes, supersedesAssetId, null);
}
}
public record PublishedAsset(long assetId, long versionId, String lifecycleStatus, String indexStatus,
@@ -1084,8 +1255,40 @@ public class AihrKnowledgeLifecycleService {
String redactedContentPreview) {
}
private record AssetRow(long id, long knowledgeId, String docId, long versionId, String lifecycleStatus,
String dataClass, String sourceVersion, String sourceName) {
public record VersionSummary(long id, int versionNo, String versionStatus, Long revisionOfVersionId,
String revisionReason, String contentSha256, String parserName,
String parserVersion, String cleaningPolicyVersion,
String classificationPolicyVersion, String semanticAnalysisStatus,
String privacyStatus, boolean published, boolean candidate,
int chunkCount, int openIssueCount, LocalDateTime createTime) {
}
public record VersionDetail(long id, int versionNo, String versionStatus, Long revisionOfVersionId,
String revisionReason, String contentSha256, String redactedSourceName,
String redactedContent, String structureProfileJson, String extractionQualityJson,
String redactionSummaryJson, String semanticAnalysisStatus,
String semanticEmbeddingModel, Integer semanticEmbeddingDimension,
String privacyStatus, String parserName, String parserVersion,
String cleaningPolicyVersion, String classificationPolicyVersion,
boolean published, boolean candidate, List<VersionChunk> chunks,
LocalDateTime createTime) {
private VersionDetail withChunks(List<VersionChunk> value) {
return new VersionDetail(id, versionNo, versionStatus, revisionOfVersionId, revisionReason,
contentSha256, redactedSourceName, redactedContent, structureProfileJson, extractionQualityJson,
redactionSummaryJson, semanticAnalysisStatus, semanticEmbeddingModel, semanticEmbeddingDimension,
privacyStatus, parserName, parserVersion, cleaningPolicyVersion, classificationPolicyVersion,
published, candidate, value, createTime);
}
}
public record VersionChunk(long id, int chunkIndex, String content, String contentSha256,
String headingPath, String contextPrefix, String locatorJson,
Long publishedFragmentId) {
}
private record AssetRow(long id, long knowledgeId, String docId, long versionId, Long publishedVersionId,
Long candidateVersionId, String lifecycleStatus, String dataClass, String sourceVersion,
String sourceName) {
}
private record VersionGate(String semanticStatus, String privacyStatus, boolean privacyReady) {
@@ -1104,7 +1307,10 @@ public class AihrKnowledgeLifecycleService {
private record Snapshot(String lifecycleStatus, String dataClass, String sourceVersion) {
}
private record IndexPayload(String docId) {
private record IndexPayload(String docId, long assetId, long versionId, long generation) {
}
private record IndexTarget(String collection, long generation) {
}
private record DuplicateMatch(long assetId, long versionId) {
@@ -0,0 +1,298 @@
package org.dromara.aihr.knowledge.quality;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class AihrKnowledgeMigrationOrchestrationService {
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper;
private final AihrKnowledgeLegacyMigrationService legacyMigrationService;
@Transactional
public MigrationRun create(String tenantId, long requesterId, CreateRun command) {
if (requesterId <= 0 || command == null || command.runKey() == null
|| !command.runKey().matches("[A-Za-z0-9._:-]{8,100}")
|| command.batchSize() < 1 || command.batchSize() > 100
|| (command.knowledgeId() != null && command.knowledgeId() <= 0)
|| (command.dryRun() && command.verifiedDryRunId() != null)
|| (!command.dryRun() && (command.verifiedDryRunId() == null || command.verifiedDryRunId() <= 0))
|| !Set.of("ALL", "LOW_RISK", "QUARANTINE_FIRST").contains(normalize(command.riskScope()))) {
throw new ServiceException("Invalid migration manifest", HttpStatus.BAD_REQUEST);
}
MigrationRun existing = findByKey(tenantId, requesterId, command.runKey());
if (existing != null) {
if (!sameManifest(existing, command)) {
throw new ServiceException("Migration run key was already used for a different manifest",
HttpStatus.CONFLICT);
}
return existing;
}
String riskScope = normalize(command.riskScope());
if (!command.dryRun()) {
MigrationRun dryRun = requireRun(tenantId, command.verifiedDryRunId(), false);
if (!dryRunCovers(dryRun, command)) {
throw new ServiceException("Formal migration requires a completed matching dry run",
HttpStatus.CONFLICT);
}
}
String manifest = tenantId + "|" + command.runKey() + "|" + command.knowledgeId() + "|"
+ riskScope + "|" + command.dryRun() + "|" + command.batchSize() + "|" + command.verifiedDryRunId();
KeyHolder key = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement statement = connection.prepareStatement("""
insert into aihr_migration_run
(tenant_id, run_key, knowledge_id, risk_scope, dry_run, batch_size,
cursor_attachment_id, status, manifest_sha256, requested_by, verified_dry_run_id,
create_time, update_time)
values (?, ?, ?, ?, ?, ?, 0, 'PLANNED', ?, ?, ?, now(), now())
""", Statement.RETURN_GENERATED_KEYS);
statement.setString(1, tenantId);
statement.setString(2, command.runKey());
if (command.knowledgeId() == null) statement.setNull(3, java.sql.Types.BIGINT);
else statement.setLong(3, command.knowledgeId());
statement.setString(4, riskScope);
statement.setBoolean(5, command.dryRun());
statement.setInt(6, command.batchSize());
statement.setString(7, AihrKnowledgeLifecycleService.sha256(manifest));
statement.setLong(8, requesterId);
if (command.verifiedDryRunId() == null) statement.setNull(9, java.sql.Types.BIGINT);
else statement.setLong(9, command.verifiedDryRunId());
return statement;
}, key);
return requireRun(tenantId, key.getKey().longValue(), false);
}
@Transactional
public MigrationBatch runNext(String tenantId, long runId, long operatorId) {
if (operatorId <= 0) throw new ServiceException("A signed-in operator is required", HttpStatus.BAD_REQUEST);
MigrationRun run = requireRun(tenantId, runId, true);
if (Set.of("COMPLETED", "COMPLETED_WITH_ERRORS", "CANCELLED").contains(run.status())) {
throw new ServiceException("Migration run is already terminal", HttpStatus.CONFLICT);
}
int batchNo = nextBatchNo(tenantId, runId);
long fromCursor = run.cursorAttachmentId();
BatchOutcome outcome;
if (run.dryRun()) {
AihrKnowledgeLegacyMigrationService.MigrationPreview preview = legacyMigrationService.previewBatch(
tenantId, run.knowledgeId(), fromCursor, run.batchSize(), run.riskScope());
outcome = new BatchOutcome(preview.discovered(), 0, 0, preview.sourceUnavailable(), List.of(),
preview.nextCursor(), preview.moreAvailable());
} else {
AihrKnowledgeLegacyMigrationService.MigrationResult result = legacyMigrationService.stageBatch(
tenantId, run.knowledgeId(), fromCursor, run.batchSize(), run.riskScope());
outcome = new BatchOutcome(result.discovered(), result.staged(), result.reparsed(),
result.quarantined(), result.failedAttachmentIds(), result.nextCursor(), result.moreAvailable());
}
String status = outcome.failedIds().isEmpty() ? "SUCCEEDED" : "PARTIAL";
jdbcTemplate.update("""
insert into aihr_migration_batch
(tenant_id, run_id, batch_no, from_cursor, to_cursor, status, discovered_count,
staged_count, reparsed_count, quarantined_count, failed_count, result_json,
create_time, finish_time)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now())
""", tenantId, runId, batchNo, fromCursor, outcome.nextCursor(), status, outcome.discovered(),
outcome.staged(), outcome.reparsed(), outcome.quarantined(), outcome.failedIds().size(), json(outcome));
outcome.failedIds().forEach(attachmentId -> jdbcTemplate.update("""
insert into aihr_migration_dead_letter
(tenant_id, run_id, attachment_id, status, attempt_count, last_error,
next_attempt_time, create_time, update_time)
values (?, ?, ?, 'OPEN', 1, 'STAGING_FAILED', date_add(now(), interval 1 minute), now(), now())
on duplicate key update status = 'OPEN', attempt_count = attempt_count + 1,
last_error = values(last_error), next_attempt_time = date_add(now(), interval 1 minute), update_time = now()
""", tenantId, runId, attachmentId));
String terminal = outcome.moreAvailable() ? "RUNNING"
: (run.failedCount() + outcome.failedIds().size() > 0 ? "COMPLETED_WITH_ERRORS" : "COMPLETED");
jdbcTemplate.update("""
update aihr_migration_run
set status = ?, cursor_attachment_id = ?,
discovered_count = discovered_count + ?, staged_count = staged_count + ?,
quarantined_count = quarantined_count + ?, failed_count = failed_count + ?,
start_time = coalesce(start_time, now()), finish_time = case when ? then now() else null end,
last_error = null, update_time = now()
where tenant_id = ? and id = ?
""", terminal, outcome.nextCursor(), outcome.discovered(), outcome.staged(), outcome.quarantined(),
outcome.failedIds().size(), !outcome.moreAvailable(), tenantId, runId);
return new MigrationBatch(runId, batchNo, fromCursor, outcome.nextCursor(), status,
outcome.discovered(), outcome.staged(), outcome.reparsed(), outcome.quarantined(),
outcome.failedIds().size(), outcome.moreAvailable());
}
@Transactional
public DeadLetter retry(String tenantId, long deadLetterId, long operatorId) {
if (operatorId <= 0) throw new ServiceException("A signed-in operator is required", HttpStatus.BAD_REQUEST);
DeadLetter dead = requireDeadLetter(tenantId, deadLetterId, true);
if ("RESOLVED".equals(dead.status())) return dead;
MigrationRun run = requireRun(tenantId, dead.runId(), false);
jdbcTemplate.update("""
update aihr_migration_dead_letter set status = 'RETRYING', update_time = now()
where tenant_id = ? and id = ?
""", tenantId, deadLetterId);
AihrKnowledgeLegacyMigrationService.MigrationResult result = legacyMigrationService.stageBatch(
tenantId, run.knowledgeId(), Math.max(0, dead.attachmentId() - 1), 1, run.riskScope());
Long assetId = jdbcTemplate.query("""
select id from aihr_data_asset where tenant_id = ? and attachment_id = ? order by id desc limit 1
""", rs -> rs.next() ? rs.getLong(1) : null, tenantId, dead.attachmentId());
boolean resolved = assetId != null && !result.failedAttachmentIds().contains(dead.attachmentId());
jdbcTemplate.update("""
update aihr_migration_dead_letter
set status = ?, attempt_count = attempt_count + 1, resolved_asset_id = ?,
last_error = ?, next_attempt_time = date_add(now(), interval least(1440, power(2, attempt_count)) minute),
update_time = now()
where tenant_id = ? and id = ?
""", resolved ? "RESOLVED" : (dead.attemptCount() >= 7 ? "ABANDONED" : "OPEN"), assetId,
resolved ? null : "RETRY_FAILED", tenantId, deadLetterId);
if (resolved) {
jdbcTemplate.update("""
update aihr_migration_run set failed_count = greatest(0, failed_count - 1), update_time = now()
where tenant_id = ? and id = ?
""", tenantId, dead.runId());
}
return requireDeadLetter(tenantId, deadLetterId, false);
}
public List<MigrationRun> runs(String tenantId, int limit) {
return jdbcTemplate.query("""
select id, run_key, knowledge_id, risk_scope, dry_run, batch_size, cursor_attachment_id,
status, discovered_count, staged_count, quarantined_count, failed_count,
manifest_sha256, requested_by, verified_dry_run_id,
create_time, start_time, finish_time, last_error
from aihr_migration_run where tenant_id = ? order by id desc limit ?
""", (rs, rowNum) -> mapRun(rs), tenantId, Math.max(1, Math.min(limit, 100)));
}
public List<DeadLetter> deadLetters(String tenantId, long runId) {
return jdbcTemplate.query("""
select id, run_id, attachment_id, status, attempt_count, last_error,
next_attempt_time, resolved_asset_id
from aihr_migration_dead_letter where tenant_id = ? and run_id = ? order by id
""", (rs, rowNum) -> mapDead(rs), tenantId, runId);
}
private MigrationRun findByKey(String tenantId, long requesterId, String key) {
List<MigrationRun> rows = jdbcTemplate.query("""
select id, run_key, knowledge_id, risk_scope, dry_run, batch_size, cursor_attachment_id,
status, discovered_count, staged_count, quarantined_count, failed_count,
manifest_sha256, requested_by, verified_dry_run_id,
create_time, start_time, finish_time, last_error
from aihr_migration_run where tenant_id = ? and requested_by = ? and run_key = ? limit 1
""", (rs, rowNum) -> mapRun(rs), tenantId, requesterId, key);
return rows.isEmpty() ? null : rows.get(0);
}
private MigrationRun requireRun(String tenantId, long runId, boolean lock) {
List<MigrationRun> rows = jdbcTemplate.query("""
select id, run_key, knowledge_id, risk_scope, dry_run, batch_size, cursor_attachment_id,
status, discovered_count, staged_count, quarantined_count, failed_count,
manifest_sha256, requested_by, verified_dry_run_id,
create_time, start_time, finish_time, last_error
from aihr_migration_run where tenant_id = ? and id = ? %s
""".formatted(lock ? "for update" : ""), (rs, rowNum) -> mapRun(rs), tenantId, runId);
if (rows.isEmpty()) throw new ServiceException("Migration run does not exist", HttpStatus.NOT_FOUND);
return rows.get(0);
}
private DeadLetter requireDeadLetter(String tenantId, long id, boolean lock) {
List<DeadLetter> rows = jdbcTemplate.query("""
select id, run_id, attachment_id, status, attempt_count, last_error,
next_attempt_time, resolved_asset_id
from aihr_migration_dead_letter where tenant_id = ? and id = ? %s
""".formatted(lock ? "for update" : ""), (rs, rowNum) -> mapDead(rs), tenantId, id);
if (rows.isEmpty()) throw new ServiceException("Migration dead letter does not exist", HttpStatus.NOT_FOUND);
return rows.get(0);
}
private int nextBatchNo(String tenantId, long runId) {
Integer value = jdbcTemplate.queryForObject("""
select coalesce(max(batch_no), 0) + 1 from aihr_migration_batch where tenant_id = ? and run_id = ?
""", Integer.class, tenantId, runId);
return value == null ? 1 : value;
}
private static MigrationRun mapRun(java.sql.ResultSet rs) throws java.sql.SQLException {
return new MigrationRun(rs.getLong("id"), rs.getString("run_key"),
rs.getObject("knowledge_id", Long.class), rs.getString("risk_scope"), rs.getBoolean("dry_run"),
rs.getInt("batch_size"), rs.getLong("cursor_attachment_id"), rs.getString("status"),
rs.getInt("discovered_count"), rs.getInt("staged_count"), rs.getInt("quarantined_count"),
rs.getInt("failed_count"), rs.getString("manifest_sha256"), rs.getLong("requested_by"),
rs.getObject("verified_dry_run_id", Long.class),
rs.getObject("create_time", LocalDateTime.class), rs.getObject("start_time", LocalDateTime.class),
rs.getObject("finish_time", LocalDateTime.class), rs.getString("last_error"));
}
private static DeadLetter mapDead(java.sql.ResultSet rs) throws java.sql.SQLException {
return new DeadLetter(rs.getLong("id"), rs.getLong("run_id"), rs.getLong("attachment_id"),
rs.getString("status"), rs.getInt("attempt_count"), rs.getString("last_error"),
rs.getObject("next_attempt_time", LocalDateTime.class), rs.getObject("resolved_asset_id", Long.class));
}
private String json(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException ex) {
throw new ServiceException("Failed to serialize migration evidence").setDetailMessage(ex.getMessage());
}
}
private static String normalize(String value) {
String normalized = value == null ? "ALL" : value.trim().toUpperCase(Locale.ROOT);
return normalized.isBlank() ? "ALL" : normalized;
}
static boolean sameManifest(MigrationRun existing, CreateRun command) {
return existing.dryRun() == command.dryRun()
&& existing.batchSize() == command.batchSize()
&& Objects.equals(existing.knowledgeId(), command.knowledgeId())
&& Objects.equals(existing.verifiedDryRunId(), command.verifiedDryRunId())
&& existing.riskScope().equals(normalize(command.riskScope()));
}
static boolean dryRunCovers(MigrationRun dryRun, CreateRun formalRun) {
return dryRun.dryRun() && "COMPLETED".equals(dryRun.status())
&& Objects.equals(dryRun.knowledgeId(), formalRun.knowledgeId())
&& dryRun.riskScope().equals(normalize(formalRun.riskScope()))
&& dryRun.batchSize() == formalRun.batchSize();
}
public record CreateRun(String runKey, Long knowledgeId, String riskScope, boolean dryRun, int batchSize,
Long verifiedDryRunId) {
}
public record MigrationRun(long id, String runKey, Long knowledgeId, String riskScope, boolean dryRun,
int batchSize, long cursorAttachmentId, String status, int discoveredCount,
int stagedCount, int quarantinedCount, int failedCount, String manifestSha256,
long requestedBy, Long verifiedDryRunId, LocalDateTime createTime, LocalDateTime startTime,
LocalDateTime finishTime, String lastError) {
}
public record MigrationBatch(long runId, int batchNo, long fromCursor, long toCursor, String status,
int discovered, int staged, int reparsed, int quarantined,
int failed, boolean moreAvailable) {
}
public record DeadLetter(long id, long runId, long attachmentId, String status, int attemptCount,
String lastError, LocalDateTime nextAttemptTime, Long resolvedAssetId) {
}
private record BatchOutcome(int discovered, int staged, int reparsed, int quarantined,
List<Long> failedIds, long nextCursor, boolean moreAvailable) {
}
}
@@ -35,6 +35,24 @@ public class AihrKnowledgeQualityController {
private final AihrKnowledgeRuleSamplingService ruleSamplingService;
private final AihrKnowledgePipelineRunService pipelineRunService;
private final AihrKnowledgeGoldenDatasetService goldenDatasetService;
private final AihrKnowledgeRolloutService rolloutService;
private final AihrKnowledgeReviewAssistanceService reviewAssistanceService;
private final AihrKnowledgeVersionGovernanceService versionGovernanceService;
private final AihrKnowledgeRetentionService retentionService;
private final AihrKnowledgeMigrationOrchestrationService migrationOrchestrationService;
private final AihrKnowledgeShadowRolloutService shadowRolloutService;
@GetMapping("/rollout/scopes")
public R<List<AihrKnowledgeRolloutService.RolloutScope>> rolloutScopes() {
return R.ok(rolloutService.list(tenantId()));
}
@PostMapping("/rollout/spaces/{knowledgeId}/transition")
public R<AihrKnowledgeRolloutService.RolloutScope> transitionRollout(
@PathVariable long knowledgeId,
@RequestBody AihrKnowledgeRolloutService.TransitionCommand request) {
return R.ok(rolloutService.transition(tenantId(), knowledgeId, reviewerId(), request));
}
@GetMapping("/assets")
public R<List<AihrKnowledgeLifecycleService.AssetSummary>> assets(
@@ -53,11 +71,157 @@ public class AihrKnowledgeQualityController {
return R.ok(lifecycleService.privacyPreview(tenantId(), assetId));
}
@GetMapping("/assets/{assetId}/versions")
public R<List<AihrKnowledgeLifecycleService.VersionSummary>> versions(@PathVariable long assetId) {
return R.ok(lifecycleService.versions(tenantId(), assetId));
}
@GetMapping("/assets/{assetId}/versions/{versionId}")
public R<AihrKnowledgeLifecycleService.VersionDetail> versionDetail(
@PathVariable long assetId, @PathVariable long versionId) {
return R.ok(lifecycleService.versionDetail(tenantId(), assetId, versionId));
}
@GetMapping("/assets/{assetId}/versions/diff")
public R<AihrKnowledgeVersionGovernanceService.VersionDiff> versionDiff(
@PathVariable long assetId, @RequestParam long fromVersionId, @RequestParam long toVersionId) {
return R.ok(versionGovernanceService.compare(
tenantId(), assetId, fromVersionId, toVersionId, reviewerId()));
}
@PostMapping("/assets/{assetId}/rollback")
public R<AihrKnowledgeVersionGovernanceService.RollbackResult> rollbackVersion(
@PathVariable long assetId,
@RequestBody AihrKnowledgeVersionGovernanceService.RollbackCommand request) {
return R.ok(versionGovernanceService.rollback(tenantId(), assetId, reviewerId(), request));
}
@GetMapping("/spaces/{knowledgeId}/generations")
public R<List<AihrKnowledgeVersionGovernanceService.GenerationSummary>> generations(
@PathVariable long knowledgeId) {
return R.ok(versionGovernanceService.generations(tenantId(), knowledgeId));
}
@GetMapping("/assets/{assetId}/impact")
public R<AihrKnowledgeRetentionService.ImpactAnalysis> deletionImpact(@PathVariable long assetId) {
return R.ok(retentionService.impact(tenantId(), assetId));
}
@PostMapping("/assets/{assetId}/purge-requests")
public R<AihrKnowledgeRetentionService.DeletionRequest> requestPurge(
@PathVariable long assetId, @RequestBody AihrKnowledgeRetentionService.PurgeRequest request) {
return R.ok(retentionService.requestPurge(tenantId(), assetId, reviewerId(), request));
}
@GetMapping("/purge-requests")
public R<List<AihrKnowledgeRetentionService.DeletionRequest>> purgeRequests(
@RequestParam(defaultValue = "") String status,
@RequestParam(defaultValue = "100") int limit) {
return R.ok(retentionService.requests(tenantId(), status, limit));
}
@PostMapping("/purge-requests/{requestId}/decision")
public R<AihrKnowledgeRetentionService.DeletionRequest> decidePurge(
@PathVariable long requestId, @RequestBody AihrKnowledgeRetentionService.Decision request) {
return R.ok(retentionService.decide(tenantId(), requestId, reviewerId(), request));
}
@PostMapping("/purge-requests/{requestId}/execute")
public R<AihrKnowledgeRetentionService.DeletionRequest> executePurge(@PathVariable long requestId) {
return R.ok(retentionService.execute(tenantId(), requestId, reviewerId()));
}
@PostMapping("/reconciliation-runs")
public R<AihrKnowledgeRetentionService.ReconciliationReport> reconcileStores() {
return R.ok(retentionService.reconcile(tenantId(), reviewerId()));
}
@PostMapping("/migration-runs")
public R<AihrKnowledgeMigrationOrchestrationService.MigrationRun> createMigrationRun(
@RequestBody AihrKnowledgeMigrationOrchestrationService.CreateRun request) {
return R.ok(migrationOrchestrationService.create(tenantId(), reviewerId(), request));
}
@GetMapping("/migration-runs")
public R<List<AihrKnowledgeMigrationOrchestrationService.MigrationRun>> migrationRuns(
@RequestParam(defaultValue = "50") int limit) {
return R.ok(migrationOrchestrationService.runs(tenantId(), limit));
}
@PostMapping("/migration-runs/{runId}/next")
public R<AihrKnowledgeMigrationOrchestrationService.MigrationBatch> runMigrationBatch(
@PathVariable long runId) {
return R.ok(migrationOrchestrationService.runNext(tenantId(), runId, reviewerId()));
}
@GetMapping("/migration-runs/{runId}/dead-letters")
public R<List<AihrKnowledgeMigrationOrchestrationService.DeadLetter>> migrationDeadLetters(
@PathVariable long runId) {
return R.ok(migrationOrchestrationService.deadLetters(tenantId(), runId));
}
@PostMapping("/migration-dead-letters/{deadLetterId}/retry")
public R<AihrKnowledgeMigrationOrchestrationService.DeadLetter> retryMigrationDeadLetter(
@PathVariable long deadLetterId) {
return R.ok(migrationOrchestrationService.retry(tenantId(), deadLetterId, reviewerId()));
}
@PostMapping("/rollout/spaces/{knowledgeId}/generation-builds")
public R<AihrKnowledgeShadowRolloutService.GenerationBuild> startGenerationBuild(
@PathVariable long knowledgeId,
@RequestBody AihrKnowledgeShadowRolloutService.BuildCommand request) {
return R.ok(shadowRolloutService.startBuild(tenantId(), knowledgeId, reviewerId(), request));
}
@PostMapping("/rollout/spaces/{knowledgeId}/generation-builds/refresh")
public R<AihrKnowledgeShadowRolloutService.GenerationBuild> refreshGenerationBuild(
@PathVariable long knowledgeId) {
return R.ok(shadowRolloutService.refreshBuild(tenantId(), knowledgeId));
}
@PostMapping("/rollout/spaces/{knowledgeId}/shadow-comparisons")
public R<AihrKnowledgeShadowRolloutService.ShadowComparison> compareShadowQuery(
@PathVariable long knowledgeId,
@RequestBody AihrKnowledgeShadowRolloutService.ComparisonCommand request) {
return R.ok(shadowRolloutService.compare(tenantId(), knowledgeId, request));
}
@PostMapping("/rollout/spaces/{knowledgeId}/activation-gates")
public R<AihrKnowledgeShadowRolloutService.ActivationGate> evaluateActivationGate(
@PathVariable long knowledgeId) {
return R.ok(shadowRolloutService.evaluateGate(tenantId(), knowledgeId, reviewerId()));
}
@PostMapping("/rollout/spaces/{knowledgeId}/activate")
public R<AihrKnowledgeShadowRolloutService.ActivationResult> activateGeneration(
@PathVariable long knowledgeId,
@RequestBody AihrKnowledgeShadowRolloutService.ActivationCommand request) {
return R.ok(shadowRolloutService.activate(tenantId(), knowledgeId, reviewerId(), request));
}
@PostMapping("/rollout/spaces/{knowledgeId}/generation-rollback")
public R<AihrKnowledgeShadowRolloutService.ActivationResult> rollbackGeneration(
@PathVariable long knowledgeId,
@RequestBody AihrKnowledgeShadowRolloutService.RollbackCommand request) {
return R.ok(shadowRolloutService.rollback(tenantId(), knowledgeId, reviewerId(), request));
}
@GetMapping("/assets/{assetId}/conflicts")
public R<List<AihrKnowledgeClaimService.ConflictView>> conflicts(@PathVariable long assetId) {
return R.ok(claimService.conflicts(tenantId(), assetId));
}
@GetMapping("/assets/{assetId}/review-package")
public R<AihrKnowledgeReviewAssistanceService.ReviewPackage> reviewPackage(@PathVariable long assetId) {
return R.ok(reviewAssistanceService.reviewPackage(tenantId(), assetId));
}
@PostMapping("/assets/review-batches")
public R<AihrKnowledgeReviewAssistanceService.BatchResult> publishLowRiskBatch(
@RequestBody AihrKnowledgeReviewAssistanceService.BatchRequest request) {
return R.ok(reviewAssistanceService.publishLowRiskBatch(tenantId(), reviewerId(), request));
}
@PostMapping("/conflicts/{conflictId}/review")
public R<AihrKnowledgeClaimService.ConflictReviewResult> reviewConflict(
@PathVariable long conflictId, @RequestBody ConflictReviewRequest request) {
@@ -54,19 +54,21 @@ public class AihrKnowledgeQualityMonitoringService {
sum(case when lifecycle_status = 'PUBLISHED' and (
source_authority is null or source_authority = '' or source_authority = 'UNKNOWN'
or source_version is null or trim(source_version) = ''
or current_version_id is null
or coalesce(published_version_id, candidate_version_id, current_version_id) is null
or not exists (
select 1 from aihr_review_decision review
where review.tenant_id = aihr_data_asset.tenant_id
and review.asset_id = aihr_data_asset.id
and review.version_id = aihr_data_asset.current_version_id
and review.version_id = coalesce(aihr_data_asset.published_version_id,
aihr_data_asset.candidate_version_id, aihr_data_asset.current_version_id)
and review.decision = 'APPROVE' and review.reviewer_type = 'HUMAN'
)
or not exists (
select 1 from aihr_chunk_revision chunk
where chunk.tenant_id = aihr_data_asset.tenant_id
and chunk.asset_id = aihr_data_asset.id
and chunk.version_id = aihr_data_asset.current_version_id
and chunk.version_id = coalesce(aihr_data_asset.published_version_id,
aihr_data_asset.candidate_version_id, aihr_data_asset.current_version_id)
and chunk.published_fragment_id is not null
)
) then 1 else 0 end) untraceable_published,
@@ -74,20 +76,23 @@ public class AihrKnowledgeQualityMonitoringService {
select 1 from aihr_quality_issue issue_row
where issue_row.tenant_id = aihr_data_asset.tenant_id
and issue_row.asset_id = aihr_data_asset.id
and issue_row.version_id = aihr_data_asset.current_version_id
and issue_row.version_id = coalesce(aihr_data_asset.published_version_id,
aihr_data_asset.candidate_version_id, aihr_data_asset.current_version_id)
and issue_row.status = 'OPEN'
) then 1 else 0 end) published_open_findings,
sum(case when lifecycle_status = 'PUBLISHED' and not exists (
select 1 from aihr_dataset_membership member
where member.tenant_id = aihr_data_asset.tenant_id
and member.version_id = aihr_data_asset.current_version_id
and member.version_id = coalesce(aihr_data_asset.published_version_id,
aihr_data_asset.candidate_version_id, aihr_data_asset.current_version_id)
and member.dataset_code = ? and member.status = 'ACTIVE'
) then 1 else 0 end) published_membership_gap,
sum(case when lifecycle_status = 'PUBLISHED' and exists (
select 1 from aihr_chunk_revision chunk
where chunk.tenant_id = aihr_data_asset.tenant_id
and chunk.asset_id = aihr_data_asset.id
and chunk.version_id = aihr_data_asset.current_version_id
and chunk.version_id = coalesce(aihr_data_asset.published_version_id,
aihr_data_asset.current_version_id)
and chunk.published_fragment_id is null
) then 1 else 0 end) published_fragment_gap
from aihr_data_asset
@@ -0,0 +1,450 @@
package org.dromara.aihr.knowledge.quality;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrSopDto.VectorIndexStatusResponse;
import org.dromara.aihr.service.AihrSopSeedService;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.tenant.helper.TenantHelper;
import org.dromara.system.service.ISysOssService;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class AihrKnowledgeRetentionService {
private static final Map<String, Integer> RETENTION_DAYS = Map.of(
"TEMPORARY", 7, "STANDARD", 90, "REGULATED", 365);
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper;
private final AihrKnowledgeLifecycleService lifecycleService;
private final AihrSopSeedService sopSeedService;
private final ISysOssService ossService;
public ImpactAnalysis impact(String tenantId, long assetId) {
AssetRetention asset = requireAsset(tenantId, assetId, false);
int fragments = count("select count(*) from aihr_chunk_revision where tenant_id = ? and asset_id = ?",
tenantId, assetId);
int publishedFragments = count("""
select count(*) from aihr_chunk_revision
where tenant_id = ? and asset_id = ? and published_fragment_id is not null
""", tenantId, assetId);
int queryEvidence = count("""
select count(*) from aihr_query_evidence e
where e.tenant_id = ? and exists (
select 1 from aihr_chunk_revision c where c.tenant_id = e.tenant_id
and c.asset_id = ? and c.published_fragment_id = e.fragment_id)
""", tenantId, assetId);
int learningTasks = asset.attachmentId() == null ? 0 : count("""
select count(*) from aihr_learning_task
where tenant_id = ? and material_attachment_id = ?
""", tenantId, asset.attachmentId());
int broadcasts = asset.attachmentId() == null ? 0 : count("""
select count(*) from aihr_broadcast_message
where tenant_id = ? and attachment_id = ?
""", tenantId, asset.attachmentId());
int generations = count("""
select count(*) from aihr_generation_version where tenant_id = ? and asset_id = ?
""", tenantId, assetId);
boolean canRequestPurge = canRequestPurge(asset.lifecycleStatus(), asset.retentionClass(),
asset.legalHold(), learningTasks, broadcasts);
List<String> blockers = new java.util.ArrayList<>();
if (asset.legalHold()) blockers.add("LEGAL_HOLD");
if ("PERMANENT".equals(asset.retentionClass())) blockers.add("PERMANENT_RETENTION");
if ("PUBLISHED".equals(asset.lifecycleStatus())) blockers.add("WITHDRAW_FIRST");
if (learningTasks > 0) blockers.add("LEARNING_TASK_REFERENCE");
if (broadcasts > 0) blockers.add("BROADCAST_REFERENCE");
return new ImpactAnalysis(assetId, asset.lifecycleStatus(), asset.retentionClass(), asset.legalHold(),
asset.attachmentId(), asset.rawOssId(), fragments, publishedFragments, queryEvidence,
learningTasks, broadcasts, generations, canRequestPurge, List.copyOf(blockers));
}
@Transactional
public DeletionRequest requestPurge(String tenantId, long assetId, long requesterId, PurgeRequest request) {
if (requesterId <= 0 || request == null || request.requestKey() == null
|| !request.requestKey().matches("[A-Za-z0-9._:-]{8,100}")
|| request.reason() == null || request.reason().isBlank()) {
throw new ServiceException("A request key, signed-in requester and reason are required",
HttpStatus.BAD_REQUEST);
}
DeletionRequest existing = findByKey(tenantId, requesterId, request.requestKey());
if (existing != null) return existing;
AssetRetention asset = requireAsset(tenantId, assetId, true);
ImpactAnalysis impact = impact(tenantId, assetId);
if (!impact.canRequestPurge()) {
throw new ServiceException("Asset cannot enter purge workflow: " + String.join(",", impact.blockers()),
HttpStatus.CONFLICT);
}
int retentionDays = retentionDays(asset.retentionClass());
LocalDateTime executeAfter = LocalDateTime.now().plusDays(retentionDays);
KeyHolder keys = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement statement = connection.prepareStatement("""
insert into aihr_deletion_request
(tenant_id, request_key, asset_id, version_id, request_type, retention_class,
reason, impact_snapshot_json, status, requester_id, requested_time, execute_after, update_time)
values (?, ?, ?, ?, 'PURGE', ?, ?, ?, 'PENDING', ?, now(), ?, now())
""", Statement.RETURN_GENERATED_KEYS);
statement.setString(1, tenantId);
statement.setString(2, request.requestKey());
statement.setLong(3, assetId);
if (asset.publishedVersionId() == null) statement.setNull(4, java.sql.Types.BIGINT);
else statement.setLong(4, asset.publishedVersionId());
statement.setString(5, asset.retentionClass());
statement.setString(6, request.reason().trim());
statement.setString(7, json(impact));
statement.setLong(8, requesterId);
statement.setObject(9, executeAfter);
return statement;
}, keys);
jdbcTemplate.update("""
update aihr_data_asset set delete_state = 'DELETE_REQUESTED', update_time = now()
where tenant_id = ? and id = ?
""", tenantId, assetId);
return requireRequest(tenantId, keys.getKey().longValue(), false);
}
@Transactional
public DeletionRequest decide(String tenantId, long requestId, long approverId, Decision command) {
if (approverId <= 0 || command == null || command.reason() == null || command.reason().isBlank()
|| !Set.of("APPROVE", "REJECT").contains(normalize(command.decision()))) {
throw new ServiceException("A valid decision, signed-in approver and reason are required",
HttpStatus.BAD_REQUEST);
}
DeletionRequest request = requireRequest(tenantId, requestId, true);
if (!"PENDING".equals(request.status())) return request;
if (request.requesterId() == approverId) {
throw new ServiceException("Requester and purge approver must be different users", HttpStatus.CONFLICT);
}
boolean approved = "APPROVE".equals(normalize(command.decision()));
jdbcTemplate.update("""
update aihr_deletion_request
set status = ?, approver_id = ?, approval_reason = ?, approved_time = now(), update_time = now()
where tenant_id = ? and id = ? and status = 'PENDING'
""", approved ? "APPROVED" : "REJECTED", approverId, command.reason().trim(), tenantId, requestId);
jdbcTemplate.update("""
update aihr_data_asset set delete_state = ?, update_time = now()
where tenant_id = ? and id = ?
""", approved ? "DELETE_APPROVED" : "ACTIVE", tenantId, request.assetId());
return requireRequest(tenantId, requestId, false);
}
@Transactional
public DeletionRequest execute(String tenantId, long requestId, long executorId) {
if (executorId <= 0) throw new ServiceException("A signed-in executor is required", HttpStatus.BAD_REQUEST);
DeletionRequest request = requireRequest(tenantId, requestId, true);
if ("EXECUTED".equals(request.status())) return request;
if (!Set.of("APPROVED", "FAILED").contains(request.status())) {
throw new ServiceException("Purge request is not approved", HttpStatus.CONFLICT);
}
if (request.requesterId() == executorId) {
throw new ServiceException("Requester cannot execute the irreversible purge", HttpStatus.CONFLICT);
}
if (LocalDateTime.now().isBefore(request.executeAfter())) {
throw new ServiceException("Retention period has not elapsed", HttpStatus.CONFLICT);
}
AssetRetention asset = requireAsset(tenantId, request.assetId(), true);
if (asset.legalHold() || "PUBLISHED".equals(asset.lifecycleStatus())) {
throw new ServiceException("Legal hold or active publication blocks purge", HttpStatus.CONFLICT);
}
jdbcTemplate.update("""
update aihr_deletion_request set status = 'EXECUTING', executed_by = ?, last_error = null, update_time = now()
where tenant_id = ? and id = ? and status in ('APPROVED','FAILED')
""", executorId, tenantId, requestId);
try {
AihrKnowledgeRolloutService.ModeGeneration target = rolloutTarget(tenantId, asset.knowledgeId());
TenantHelper.dynamic(tenantId, () -> {
sopSeedService.deleteVectorDocument(asset.knowledgeId(), asset.docId(),
target.governedCollection(), target.activeGeneration());
return null;
});
if (asset.rawOssId() != null && count(
"select count(*) from aihr_knowledge_attach where tenant_id = ? and oss_id = ?",
tenantId, asset.rawOssId()) <= 1) {
ossService.deleteWithValidByIds(List.of(asset.rawOssId()), true);
}
purgeDatabase(tenantId, asset);
jdbcTemplate.update("""
update aihr_deletion_request
set status = 'EXECUTED', executed_time = now(), last_error = null, update_time = now()
where tenant_id = ? and id = ?
""", tenantId, requestId);
} catch (RuntimeException ex) {
jdbcTemplate.update("""
update aihr_deletion_request set status = 'FAILED', last_error = left(?, 1000), update_time = now()
where tenant_id = ? and id = ?
""", ex.getMessage(), tenantId, requestId);
return requireRequest(tenantId, requestId, false);
}
return requireRequest(tenantId, requestId, false);
}
public List<DeletionRequest> requests(String tenantId, String status, int limit) {
String normalized = normalize(status);
int bounded = Math.max(1, Math.min(limit, 200));
return jdbcTemplate.query("""
select id, request_key, asset_id, version_id, request_type, retention_class, reason,
status, requester_id, requested_time, approver_id, approval_reason, approved_time,
execute_after, executed_by, executed_time, last_error
from aihr_deletion_request
where tenant_id = ? and (? = '' or status = ?)
order by id desc limit ?
""", (rs, rowNum) -> mapRequest(rs), tenantId, normalized, normalized, bounded);
}
@Transactional
public ReconciliationReport reconcile(String tenantId, long reviewerId) {
int assets = count("select count(*) from aihr_data_asset where tenant_id = ?", tenantId);
int fragments = count("select count(*) from aihr_knowledge_fragment where tenant_id = ?", tenantId);
VectorIndexStatusResponse vector = TenantHelper.dynamic(tenantId, sopSeedService::vectorIndexStatus);
List<ReconciliationIssue> issues = new java.util.ArrayList<>();
jdbcTemplate.query("""
select a.id, a.attachment_id, a.raw_oss_id,
case when a.attachment_id is not null and ka.id is null then 1 else 0 end attachment_missing,
case when a.raw_oss_id is not null and o.oss_id is null then 1 else 0 end oss_missing,
case when a.lifecycle_status = 'PUBLISHED' and a.index_status = 'READY'
and not exists (select 1 from aihr_chunk_revision c where c.tenant_id = a.tenant_id
and c.asset_id = a.id and c.version_id = a.published_version_id
and c.vector_point_id is not null) then 1 else 0 end vector_lineage_missing
from aihr_data_asset a
left join aihr_knowledge_attach ka on ka.tenant_id = a.tenant_id and ka.id = a.attachment_id
left join sys_oss o on binary o.tenant_id = binary a.tenant_id and o.oss_id = a.raw_oss_id
where a.tenant_id = ?
""", rs -> {
while (rs.next()) {
long assetId = rs.getLong("id");
if (rs.getInt("attachment_missing") == 1) issues.add(issue(assetId, "ATTACHMENT_MISSING", "ERROR"));
if (rs.getInt("oss_missing") == 1) issues.add(issue(assetId, "OSS_METADATA_MISSING", "ERROR"));
if (rs.getInt("vector_lineage_missing") == 1) issues.add(issue(assetId, "VECTOR_LINEAGE_MISSING", "WARNING"));
}
}, tenantId);
if (!Boolean.TRUE.equals(vector.matched())) {
issues.add(issue(null, "QDRANT_COUNT_MISMATCH", "ERROR"));
}
ReconciliationSummary summary = new ReconciliationSummary(assets, fragments,
vector.qdrantPoints(), vector.fragments(), vector.ungovernedFragments(), vector.message());
KeyHolder key = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement statement = connection.prepareStatement("""
insert into aihr_reconciliation_run
(tenant_id, scope_type, status, mysql_assets, mysql_fragments, qdrant_points,
issue_count, summary_json, created_by, create_time, finish_time)
values (?, 'TENANT', ?, ?, ?, ?, ?, ?, ?, now(), now())
""", Statement.RETURN_GENERATED_KEYS);
statement.setString(1, tenantId);
statement.setString(2, issues.isEmpty() ? "PASSED" : "ISSUES_FOUND");
statement.setInt(3, assets);
statement.setInt(4, fragments);
if (vector.qdrantPoints() == null) statement.setNull(5, java.sql.Types.BIGINT);
else statement.setLong(5, vector.qdrantPoints());
statement.setInt(6, issues.size());
statement.setString(7, json(summary));
if (reviewerId <= 0) statement.setNull(8, java.sql.Types.BIGINT); else statement.setLong(8, reviewerId);
return statement;
}, key);
long runId = key.getKey().longValue();
issues.forEach(issue -> jdbcTemplate.update("""
insert into aihr_reconciliation_issue
(tenant_id, run_id, asset_id, issue_code, severity, evidence_json, status, create_time)
values (?, ?, ?, ?, ?, json_object('source','automated-reconciliation'), 'OPEN', now())
""", tenantId, runId, issue.assetId(), issue.issueCode(), issue.severity()));
return new ReconciliationReport(runId, issues.isEmpty() ? "PASSED" : "ISSUES_FOUND",
summary, List.copyOf(issues));
}
private void purgeDatabase(String tenantId, AssetRetention asset) {
safeUpdate("delete from aihr_query_evidence where tenant_id = ? and asset_id = ?",
tenantId, asset.assetId());
safeUpdate("delete from aihr_knowledge_fragment_locator where tenant_id = ? and doc_id = ?",
tenantId, asset.docId());
safeUpdate("delete from aihr_knowledge_fragment where tenant_id = ? and knowledge_id = ? and doc_id = ?",
tenantId, asset.knowledgeId(), asset.docId());
safeUpdate("""
delete from aihr_claim_conflict where tenant_id = ? and (
left_claim_id in (select id from aihr_knowledge_claim where tenant_id = ? and asset_id = ?)
or right_claim_id in (select id from aihr_knowledge_claim where tenant_id = ? and asset_id = ?))
""", tenantId, tenantId, asset.assetId(), tenantId, asset.assetId());
safeUpdate("""
delete from aihr_duplicate_relation where tenant_id = ?
and (left_asset_id = ? or right_asset_id = ?)
""", tenantId, asset.assetId(), asset.assetId());
safeUpdate("delete from aihr_review_batch_item where tenant_id = ? and asset_id = ?",
tenantId, asset.assetId());
safeUpdate("""
delete from aihr_dataset_membership where tenant_id = ? and version_id in
(select id from aihr_data_version where tenant_id = ? and asset_id = ?)
""", tenantId, tenantId, asset.assetId());
safeUpdate("delete from aihr_quality_assessment where tenant_id = ? and asset_id = ?",
tenantId, asset.assetId());
for (String table : List.of("aihr_generation_version", "aihr_index_outbox", "aihr_quality_issue",
"aihr_review_assistance", "aihr_version_diff", "aihr_version_rollback")) {
safeUpdate("delete from " + table + " where tenant_id = ? and asset_id = ?", tenantId, asset.assetId());
}
safeUpdate("delete from aihr_knowledge_claim where tenant_id = ? and asset_id = ?", tenantId, asset.assetId());
safeUpdate("delete from aihr_review_decision where tenant_id = ? and asset_id = ?", tenantId, asset.assetId());
safeUpdate("delete from aihr_chunk_revision where tenant_id = ? and asset_id = ?", tenantId, asset.assetId());
safeUpdate("delete from aihr_data_version where tenant_id = ? and asset_id = ?", tenantId, asset.assetId());
safeUpdate("delete from aihr_data_asset where tenant_id = ? and id = ?", tenantId, asset.assetId());
if (asset.attachmentId() != null) {
safeUpdate("delete from aihr_knowledge_attach where tenant_id = ? and id = ?",
tenantId, asset.attachmentId());
}
}
private AssetRetention requireAsset(String tenantId, long assetId, boolean lock) {
List<AssetRetention> rows = jdbcTemplate.query("""
select id, knowledge_id, attachment_id, doc_id, raw_oss_id, lifecycle_status,
published_version_id, retention_class, legal_hold
from aihr_data_asset where tenant_id = ? and id = ? %s
""".formatted(lock ? "for update" : ""), (rs, rowNum) -> new AssetRetention(
rs.getLong("id"), rs.getLong("knowledge_id"), rs.getObject("attachment_id", Long.class),
rs.getString("doc_id"), rs.getObject("raw_oss_id", Long.class), rs.getString("lifecycle_status"),
rs.getObject("published_version_id", Long.class), rs.getString("retention_class"),
rs.getBoolean("legal_hold")), tenantId, assetId);
if (rows.isEmpty()) throw new ServiceException("Data asset does not exist", HttpStatus.NOT_FOUND);
return rows.get(0);
}
private AihrKnowledgeRolloutService.ModeGeneration rolloutTarget(String tenantId, long knowledgeId) {
List<AihrKnowledgeRolloutService.ModeGeneration> rows = jdbcTemplate.query("""
select mode, governed_collection, active_generation from aihr_knowledge_rollout_scope
where tenant_id = ? and knowledge_id = ? limit 1
""", (rs, rowNum) -> new AihrKnowledgeRolloutService.ModeGeneration(rs.getString("mode"),
rs.getString("governed_collection"), rs.getLong("active_generation")), tenantId, knowledgeId);
return rows.isEmpty() ? new AihrKnowledgeRolloutService.ModeGeneration("LEGACY",
AihrKnowledgeRolloutService.DEFAULT_GOVERNED_COLLECTION, 1L) : rows.get(0);
}
private DeletionRequest findByKey(String tenantId, long requesterId, String requestKey) {
List<DeletionRequest> rows = jdbcTemplate.query("""
select id, request_key, asset_id, version_id, request_type, retention_class, reason,
status, requester_id, requested_time, approver_id, approval_reason, approved_time,
execute_after, executed_by, executed_time, last_error
from aihr_deletion_request where tenant_id = ? and requester_id = ? and request_key = ? limit 1
""", (rs, rowNum) -> mapRequest(rs), tenantId, requesterId, requestKey);
return rows.isEmpty() ? null : rows.get(0);
}
private DeletionRequest requireRequest(String tenantId, long requestId, boolean lock) {
List<DeletionRequest> rows = jdbcTemplate.query("""
select id, request_key, asset_id, version_id, request_type, retention_class, reason,
status, requester_id, requested_time, approver_id, approval_reason, approved_time,
execute_after, executed_by, executed_time, last_error
from aihr_deletion_request where tenant_id = ? and id = ? %s
""".formatted(lock ? "for update" : ""), (rs, rowNum) -> mapRequest(rs), tenantId, requestId);
if (rows.isEmpty()) throw new ServiceException("Deletion request does not exist", HttpStatus.NOT_FOUND);
return rows.get(0);
}
private static DeletionRequest mapRequest(java.sql.ResultSet rs) throws java.sql.SQLException {
return new DeletionRequest(rs.getLong("id"), rs.getString("request_key"), rs.getLong("asset_id"),
rs.getObject("version_id", Long.class), rs.getString("request_type"), rs.getString("retention_class"),
rs.getString("reason"), rs.getString("status"), rs.getLong("requester_id"),
rs.getObject("requested_time", LocalDateTime.class), rs.getObject("approver_id", Long.class),
rs.getString("approval_reason"), rs.getObject("approved_time", LocalDateTime.class),
rs.getObject("execute_after", LocalDateTime.class), rs.getObject("executed_by", Long.class),
rs.getObject("executed_time", LocalDateTime.class), rs.getString("last_error"));
}
private int count(String sql, Object... args) {
try {
Integer value = jdbcTemplate.queryForObject(sql, Integer.class, args);
return value == null ? 0 : value;
} catch (DataAccessException ignored) {
return 0;
}
}
private void safeUpdate(String sql, Object... args) {
try {
jdbcTemplate.update(sql, args);
} catch (DataAccessException ignored) {
// Optional consumers can be absent in an incremental deployment; reconciliation records residue.
}
}
private String json(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException ex) {
throw new ServiceException("Failed to serialize governance evidence").setDetailMessage(ex.getMessage());
}
}
static int retentionDays(String retentionClass) {
String normalized = normalize(retentionClass);
if ("PERMANENT".equals(normalized)) {
throw new ServiceException("Permanent retention assets cannot be purged", HttpStatus.CONFLICT);
}
Integer days = RETENTION_DAYS.get(normalized);
if (days == null) throw new ServiceException("Unknown retention class", HttpStatus.BAD_REQUEST);
return days;
}
static boolean canRequestPurge(String lifecycleStatus, String retentionClass, boolean legalHold,
int learningTasks, int broadcasts) {
return !legalHold && !"PERMANENT".equals(normalize(retentionClass))
&& !"PUBLISHED".equals(normalize(lifecycleStatus)) && learningTasks == 0 && broadcasts == 0;
}
private static String normalize(String value) {
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
}
private static ReconciliationIssue issue(Long assetId, String code, String severity) {
return new ReconciliationIssue(assetId, code, severity);
}
public record PurgeRequest(String requestKey, String reason) {
}
public record Decision(String decision, String reason) {
}
public record ImpactAnalysis(long assetId, String lifecycleStatus, String retentionClass,
boolean legalHold, Long attachmentId, Long rawOssId, int chunks,
int publishedFragments, int queryEvidence, int learningTasks,
int broadcasts, int generations, boolean canRequestPurge,
List<String> blockers) {
}
public record DeletionRequest(long id, String requestKey, long assetId, Long versionId,
String requestType, String retentionClass, String reason, String status,
long requesterId, LocalDateTime requestedTime, Long approverId,
String approvalReason, LocalDateTime approvedTime, LocalDateTime executeAfter,
Long executedBy, LocalDateTime executedTime, String lastError) {
}
public record ReconciliationReport(long runId, String status, ReconciliationSummary summary,
List<ReconciliationIssue> issues) {
}
public record ReconciliationSummary(int mysqlAssets, int mysqlFragments, Long qdrantPoints,
Integer governedFragments, Integer ungovernedFragments,
String vectorMessage) {
}
public record ReconciliationIssue(Long assetId, String issueCode, String severity) {
}
private record AssetRetention(long assetId, long knowledgeId, Long attachmentId, String docId,
Long rawOssId, String lifecycleStatus, Long publishedVersionId,
String retentionClass, boolean legalHold) {
}
}
@@ -0,0 +1,334 @@
package org.dromara.aihr.knowledge.quality;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.Statement;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class AihrKnowledgeReviewAssistanceService {
public static final String POLICY_VERSION = "review-assist-v1";
private static final Set<String> HIGH_RISK_CODES = Set.of(
"PII_DETECTED", "SECRET_DETECTED", "CROSS_TENANT_REFERENCE", "PROMPT_INJECTION_SUSPECTED",
"VERSION_CONFLICT", "EXPERT_CONFLICT", "SOURCE_EVIDENCE_INSUFFICIENT", "POLICY_EXPIRED");
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper;
private final AihrKnowledgeLifecycleService lifecycleService;
private final AihrKnowledgeClaimService claimService;
@Transactional
public ReviewPackage reviewPackage(String tenantId, long assetId) {
AihrKnowledgeLifecycleService.VersionSummary candidate = lifecycleService.versions(tenantId, assetId).stream()
.filter(AihrKnowledgeLifecycleService.VersionSummary::candidate)
.findFirst()
.orElseThrow(() -> new ServiceException("Asset has no review candidate", HttpStatus.CONFLICT));
AihrKnowledgeLifecycleService.VersionDetail detail = lifecycleService.versionDetail(
tenantId, assetId, candidate.id());
List<AihrKnowledgeLifecycleService.QualityIssue> issues = lifecycleService.issues(tenantId, assetId).stream()
.filter(issue -> "OPEN".equals(issue.status()))
.toList();
List<DuplicateEvidence> duplicates = duplicateEvidence(tenantId, assetId, candidate.id());
List<AihrKnowledgeClaimService.ConflictView> conflicts = claimService.conflicts(tenantId, assetId);
AssetContext asset = assetContext(tenantId, assetId);
String riskLevel = riskLevel(issues, conflicts);
boolean batchEligible = "LOW".equals(riskLevel)
&& Set.of("COMPLETE", "NOT_REQUIRED").contains(detail.semanticAnalysisStatus())
&& Set.of("CLEAN", "REDACTED").contains(detail.privacyStatus())
&& !"NORMATIVE_KNOWLEDGE".equals(asset.dataClass());
List<String> headings = detail.chunks().stream().map(AihrKnowledgeLifecycleService.VersionChunk::headingPath)
.filter(value -> value != null && !value.isBlank()).distinct().limit(12).toList();
List<String> reasonCodes = issues.stream().map(AihrKnowledgeLifecycleService.QualityIssue::reasonCode)
.distinct().toList();
ReviewSummary summary = new ReviewSummary(asset.sourceName(), asset.sourceType(), asset.sourceAuthority(),
asset.sourceVersion(), asset.dataClass(), truncate(detail.redactedContent(), 1200), headings,
detail.chunks().size(), issues.size(), duplicates.size(), conflicts.size());
ReviewSuggestion suggestion = new ReviewSuggestion(
"LOW".equals(riskLevel) ? "PUBLISH" : "REVIEW",
batchEligible,
reasonCodes,
recommendedActions(issues),
"Deterministic evidence package only; a signed-in human must make the final decision");
jdbcTemplate.update("""
insert into aihr_review_assistance
(tenant_id, asset_id, version_id, policy_version, risk_level, summary_json,
suggestion_json, status, generated_time)
values (?, ?, ?, ?, ?, ?, ?, 'GENERATED', now())
on duplicate key update risk_level = values(risk_level), summary_json = values(summary_json),
suggestion_json = values(suggestion_json), status = 'GENERATED', generated_time = now(),
consumed_by = null, consumed_time = null
""", tenantId, assetId, candidate.id(), POLICY_VERSION, riskLevel, json(summary), json(suggestion));
return new ReviewPackage(assetId, candidate.id(), candidate.versionNo(), riskLevel, batchEligible,
summary, suggestion, issues, duplicates, conflicts, detail.createTime());
}
public BatchResult publishLowRiskBatch(String tenantId, long reviewerId, BatchRequest request) {
requireBatch(reviewerId, request);
BatchResult existing = existingBatch(tenantId, reviewerId, request.batchKey());
if (existing != null) {
return existing;
}
long batchId;
try {
batchId = insertBatch(tenantId, reviewerId, request);
} catch (DuplicateKeyException ignored) {
BatchResult raced = existingBatch(tenantId, reviewerId, request.batchKey());
if (raced != null) return raced;
throw ignored;
}
int succeeded = 0;
int failed = 0;
for (BatchItem requestItem : request.items()) {
try {
ReviewPackage reviewPackage = reviewPackage(tenantId, requestItem.assetId());
if (!reviewPackage.batchEligible()) {
throw new ServiceException("Only LOW risk candidates can use batch review", HttpStatus.CONFLICT);
}
if (requestItem.expectedVersionId() != null
&& requestItem.expectedVersionId() != reviewPackage.versionId()) {
throw new ServiceException("Candidate version changed; reload the review package",
HttpStatus.CONFLICT);
}
AihrKnowledgeLifecycleService.ReviewCommand command = withBatchReason(
requestItem.review(), request.reason(), reviewPackage.versionId());
AihrKnowledgeLifecycleService.PublishedAsset published = lifecycleService.approveAndPublish(
tenantId, requestItem.assetId(), reviewerId, command);
insertBatchItem(tenantId, batchId, requestItem.assetId(), reviewPackage.versionId(),
reviewPackage.riskLevel(), "SUCCEEDED", published.reviewId(), null, null);
jdbcTemplate.update("""
update aihr_review_assistance set status = 'CONSUMED', consumed_by = ?, consumed_time = now()
where tenant_id = ? and asset_id = ? and version_id = ? and policy_version = ?
""", reviewerId, tenantId, requestItem.assetId(), reviewPackage.versionId(), POLICY_VERSION);
succeeded++;
} catch (RuntimeException ex) {
insertBatchItem(tenantId, batchId, requestItem.assetId(), requestItem.expectedVersionId(),
"UNKNOWN", "FAILED", null, errorCode(ex), truncate(ex.getMessage(), 1000));
failed++;
}
}
String status = failed == 0 ? "SUCCEEDED" : succeeded == 0 ? "FAILED" : "PARTIAL";
jdbcTemplate.update("""
update aihr_review_batch set succeeded_count = ?, failed_count = ?, status = ?, finish_time = now()
where tenant_id = ? and id = ? and status = 'PROCESSING'
""", succeeded, failed, status, tenantId, batchId);
return batchResult(tenantId, batchId);
}
private AihrKnowledgeLifecycleService.ReviewCommand withBatchReason(
AihrKnowledgeLifecycleService.ReviewCommand value, String batchReason, long expectedVersionId) {
if (value == null) {
throw new ServiceException("Review metadata is required for every batch item", HttpStatus.BAD_REQUEST);
}
String reason = value.reason() == null || value.reason().isBlank() ? batchReason : value.reason();
if (value.expectedVersionId() != null && value.expectedVersionId() != expectedVersionId) {
throw new ServiceException("Candidate version changed; reload the review package", HttpStatus.CONFLICT);
}
return new AihrKnowledgeLifecycleService.ReviewCommand(reason, value.usageType(), value.sourceAuthority(),
value.sourceVersion(), value.applicableRegion(), value.applicableProject(), value.applicableRole(),
value.effectiveFrom(), value.effectiveTo(), value.acceptedReasonCodes(), value.supersedesAssetId(),
expectedVersionId);
}
private long insertBatch(String tenantId, long reviewerId, BatchRequest request) {
KeyHolder keys = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
var statement = connection.prepareStatement("""
insert into aihr_review_batch
(tenant_id, batch_key, reason, requested_count, succeeded_count, failed_count,
status, reviewer_id, create_time)
values (?, ?, ?, ?, 0, 0, 'PROCESSING', ?, now())
""", Statement.RETURN_GENERATED_KEYS);
statement.setString(1, tenantId);
statement.setString(2, request.batchKey().trim());
statement.setString(3, request.reason().trim());
statement.setInt(4, request.items().size());
statement.setLong(5, reviewerId);
return statement;
}, keys);
Number key = keys.getKey();
if (key == null) throw new ServiceException("Failed to create review batch", HttpStatus.ERROR);
return key.longValue();
}
private void insertBatchItem(String tenantId, long batchId, long assetId, Long versionId, String riskLevel,
String status, Long reviewId, String errorCode, String errorMessage) {
jdbcTemplate.update("""
insert into aihr_review_batch_item
(tenant_id, batch_id, asset_id, version_id, risk_level, decision, status,
review_id, error_code, error_message, create_time)
values (?, ?, ?, ?, ?, 'PUBLISH', ?, ?, ?, ?, now())
on duplicate key update status = values(status), review_id = values(review_id),
error_code = values(error_code), error_message = values(error_message)
""", tenantId, batchId, assetId, versionId == null ? 0L : versionId,
riskLevel, status, reviewId, errorCode, errorMessage);
}
private BatchResult existingBatch(String tenantId, long reviewerId, String batchKey) {
List<Long> ids = jdbcTemplate.queryForList("""
select id from aihr_review_batch
where tenant_id = ? and reviewer_id = ? and batch_key = ? limit 1
""", Long.class, tenantId, reviewerId, batchKey == null ? "" : batchKey.trim());
return ids.isEmpty() ? null : batchResult(tenantId, ids.get(0));
}
private BatchResult batchResult(String tenantId, long batchId) {
List<BatchHeader> rows = jdbcTemplate.query("""
select id, batch_key, status, requested_count, succeeded_count, failed_count,
reviewer_id, create_time, finish_time
from aihr_review_batch where tenant_id = ? and id = ?
""", (rs, rowNum) -> new BatchHeader(rs.getLong("id"), rs.getString("batch_key"),
rs.getString("status"), rs.getInt("requested_count"), rs.getInt("succeeded_count"),
rs.getInt("failed_count"), rs.getLong("reviewer_id"),
rs.getObject("create_time", LocalDateTime.class),
rs.getObject("finish_time", LocalDateTime.class)), tenantId, batchId);
if (rows.isEmpty()) throw new ServiceException("Review batch does not exist", HttpStatus.NOT_FOUND);
BatchHeader row = rows.get(0);
return new BatchResult(row.batchId(), row.batchKey(), row.status(), row.requestedCount(),
row.succeededCount(), row.failedCount(), row.reviewerId(), batchItems(tenantId, batchId),
row.createTime(), row.finishTime());
}
private List<BatchItemResult> batchItems(String tenantId, long batchId) {
return jdbcTemplate.query("""
select asset_id, version_id, risk_level, status, review_id, error_code, error_message
from aihr_review_batch_item where tenant_id = ? and batch_id = ? order by id
""", (rs, rowNum) -> new BatchItemResult(rs.getLong("asset_id"), rs.getLong("version_id"),
rs.getString("risk_level"), rs.getString("status"), rs.getObject("review_id", Long.class),
rs.getString("error_code"), rs.getString("error_message")), tenantId, batchId);
}
private AssetContext assetContext(String tenantId, long assetId) {
List<AssetContext> rows = jdbcTemplate.query("""
select source_name, source_type, source_authority, source_version, data_class
from aihr_data_asset where tenant_id = ? and id = ? limit 1
""", (rs, rowNum) -> new AssetContext(rs.getString("source_name"), rs.getString("source_type"),
rs.getString("source_authority"), rs.getString("source_version"), rs.getString("data_class")),
tenantId, assetId);
if (rows.isEmpty()) throw new ServiceException("Data asset not found", HttpStatus.NOT_FOUND);
return rows.get(0);
}
private List<DuplicateEvidence> duplicateEvidence(String tenantId, long assetId, long versionId) {
return jdbcTemplate.query("""
select id, relation_type, similarity_score, status,
case when left_version_id = ? then right_asset_id else left_asset_id end related_asset_id,
case when left_version_id = ? then right_version_id else left_version_id end related_version_id
from aihr_duplicate_relation
where tenant_id = ? and (left_version_id = ? or right_version_id = ?)
order by similarity_score desc, id
""", (rs, rowNum) -> new DuplicateEvidence(rs.getLong("id"), rs.getString("relation_type"),
rs.getObject("similarity_score", Double.class), rs.getString("status"),
rs.getLong("related_asset_id"), rs.getLong("related_version_id")),
versionId, versionId, tenantId, versionId, versionId);
}
static String riskLevel(List<AihrKnowledgeLifecycleService.QualityIssue> issues,
List<AihrKnowledgeClaimService.ConflictView> conflicts) {
if (issues.stream().anyMatch(issue -> "HARD".equals(issue.gateType())
|| "CRITICAL".equals(issue.severity()))) return "CRITICAL";
if (conflicts.stream().anyMatch(conflict -> Set.of("PENDING_REVIEW", "CONFIRMED").contains(conflict.status()))
|| issues.stream().anyMatch(issue ->
"ERROR".equals(issue.severity()) || HIGH_RISK_CODES.contains(issue.reasonCode()))) return "HIGH";
return issues.isEmpty() ? "LOW" : "MEDIUM";
}
private static List<String> recommendedActions(
List<AihrKnowledgeLifecycleService.QualityIssue> issues) {
return new ArrayList<>(issues.stream()
.map(AihrKnowledgeLifecycleService.QualityIssue::recommendedAction)
.filter(value -> value != null && !value.isBlank())
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)));
}
private static void requireBatch(long reviewerId, BatchRequest request) {
if (reviewerId <= 0 || request == null || request.batchKey() == null
|| !request.batchKey().matches("[A-Za-z0-9._:-]{8,100}")
|| request.reason() == null || request.reason().isBlank()
|| request.items() == null || request.items().isEmpty() || request.items().size() > 50
|| request.items().stream().anyMatch(item -> item == null || item.assetId() <= 0)) {
throw new ServiceException("Invalid low-risk review batch", HttpStatus.BAD_REQUEST);
}
}
private String json(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException ex) {
throw new ServiceException("Failed to serialize review assistance").setDetailMessage(ex.getMessage());
}
}
private static String errorCode(RuntimeException ex) {
return ex instanceof ServiceException ? "REVIEW_REJECTED" : ex.getClass().getSimpleName().toUpperCase(Locale.ROOT);
}
private static String truncate(String value, int max) {
if (value == null || value.length() <= max) return value;
return value.substring(0, max);
}
public record ReviewPackage(long assetId, long versionId, int versionNo, String riskLevel,
boolean batchEligible, ReviewSummary summary, ReviewSuggestion suggestion,
List<AihrKnowledgeLifecycleService.QualityIssue> issues,
List<DuplicateEvidence> duplicates,
List<AihrKnowledgeClaimService.ConflictView> conflicts,
LocalDateTime versionCreatedTime) {
}
public record ReviewSummary(String sourceName, String sourceType, String sourceAuthority,
String sourceVersion, String dataClass, String contentPreview,
List<String> headings, int chunkCount, int issueCount,
int duplicateCount, int conflictCount) {
}
public record ReviewSuggestion(String decision, boolean batchEligible, List<String> reasonCodes,
List<String> recommendedActions, String disclaimer) {
}
public record DuplicateEvidence(long id, String relationType, Double similarityScore, String status,
long relatedAssetId, long relatedVersionId) {
}
public record BatchRequest(String batchKey, String reason, List<BatchItem> items) {
}
public record BatchItem(long assetId, Long expectedVersionId,
AihrKnowledgeLifecycleService.ReviewCommand review) {
}
public record BatchResult(long batchId, String batchKey, String status, int requestedCount,
int succeededCount, int failedCount, long reviewerId,
List<BatchItemResult> items, LocalDateTime createTime,
LocalDateTime finishTime) {
}
public record BatchItemResult(long assetId, long versionId, String riskLevel, String status,
Long reviewId, String errorCode, String errorMessage) {
}
private record AssetContext(String sourceName, String sourceType, String sourceAuthority,
String sourceVersion, String dataClass) {
}
private record BatchHeader(long batchId, String batchKey, String status, int requestedCount,
int succeededCount, int failedCount, long reviewerId,
LocalDateTime createTime, LocalDateTime finishTime) {
}
}
@@ -0,0 +1,228 @@
package org.dromara.aihr.knowledge.quality;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Locale;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class AihrKnowledgeRolloutService {
public static final String DEFAULT_GOVERNED_COLLECTION = "aihr_knowledge_governed_v1";
private static final Set<String> MODES = Set.of("LEGACY", "SHADOW", "ENFORCED");
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper;
/**
* SQL predicate shared by retrieval, citation hydration and neighbouring text reads.
* Missing scope rows deliberately serve legacy fragments. Governed fragments are excluded
* from LEGACY/SHADOW so a staged or published candidate cannot duplicate the old corpus.
*/
public static String servingFragmentExistsSql(String fragmentAlias) {
String mode = rolloutModeSql(fragmentAlias + ".tenant_id", fragmentAlias + ".knowledge_id");
return "((" + mode + " in ('LEGACY','SHADOW') and not exists (" +
"select 1 from aihr_chunk_revision rollout_chunk " +
"where rollout_chunk.tenant_id = " + fragmentAlias + ".tenant_id " +
"and rollout_chunk.published_fragment_id = " + fragmentAlias + ".id)) " +
"or (" + mode + " = 'ENFORCED' and " + publishedFragmentExistsSql(fragmentAlias) + "))";
}
public static String servingAttachmentExistsSql(String attachmentAlias) {
String mode = rolloutModeSql(attachmentAlias + ".tenant_id", attachmentAlias + ".knowledge_id");
return "((" + mode + " in ('LEGACY','SHADOW')) or (" + mode + " = 'ENFORCED' and exists (" +
"select 1 from aihr_data_asset rollout_asset " +
"join aihr_dataset_membership rollout_dataset on rollout_dataset.tenant_id = rollout_asset.tenant_id " +
"and rollout_dataset.version_id = coalesce(rollout_asset.published_version_id, rollout_asset.current_version_id) " +
"and rollout_dataset.dataset_code = 'production' and rollout_dataset.status = 'ACTIVE' " +
"where rollout_asset.tenant_id = " + attachmentAlias + ".tenant_id " +
"and rollout_asset.attachment_id = " + attachmentAlias + ".id " +
"and rollout_asset.knowledge_id = " + attachmentAlias + ".knowledge_id " +
"and rollout_asset.doc_id = " + attachmentAlias + ".doc_id " +
"and rollout_asset.lifecycle_status = 'PUBLISHED' " +
"and rollout_asset.trust_level = 'HUMAN_VERIFIED' " +
"and (rollout_asset.effective_from is null or rollout_asset.effective_from <= current_date()) " +
"and (rollout_asset.effective_to is null or rollout_asset.effective_to >= current_date())" +
")))";
}
public List<RolloutScope> list(String tenantId) {
return jdbcTemplate.query("""
select k.id knowledge_id, k.code, k.name,
coalesce(s.mode, 'LEGACY') mode,
coalesce(s.governed_collection, ?) governed_collection,
coalesce(s.active_generation, 1) active_generation,
s.candidate_generation, s.updated_by, s.reason, s.update_time,
(select count(*) from aihr_knowledge_attach a
where a.tenant_id = k.tenant_id and a.knowledge_id = k.id and a.status = 2) ready_attachments,
(select count(*) from aihr_knowledge_attach a
where a.tenant_id = k.tenant_id and a.knowledge_id = k.id and a.status = 2
and not exists (
select 1 from aihr_data_asset governed
join aihr_dataset_membership dataset on dataset.tenant_id = governed.tenant_id
and dataset.version_id = coalesce(governed.published_version_id, governed.current_version_id)
and dataset.dataset_code = 'production' and dataset.status = 'ACTIVE'
where governed.tenant_id = a.tenant_id and governed.knowledge_id = a.knowledge_id
and governed.attachment_id = a.id and governed.doc_id = a.doc_id
and governed.lifecycle_status = 'PUBLISHED'
and governed.trust_level = 'HUMAN_VERIFIED'
and governed.index_status = 'READY'
)) enforcement_blockers
from aihr_knowledge_info k
left join aihr_knowledge_rollout_scope s
on s.tenant_id = k.tenant_id and s.knowledge_id = k.id
where k.tenant_id = ? and k.status = 'ACTIVE'
order by k.id
""", (rs, rowNum) -> new RolloutScope(
rs.getLong("knowledge_id"), rs.getString("code"), rs.getString("name"),
rs.getString("mode"), rs.getString("governed_collection"), rs.getLong("active_generation"),
rs.getObject("candidate_generation", Long.class), rs.getObject("updated_by", Long.class),
rs.getString("reason"), rs.getTimestamp("update_time") == null ? null
: rs.getTimestamp("update_time").toLocalDateTime(),
rs.getInt("ready_attachments"), rs.getInt("enforcement_blockers")),
DEFAULT_GOVERNED_COLLECTION, tenantId);
}
public ModeGeneration modeGeneration(String tenantId, long knowledgeId) {
try {
List<ModeGeneration> rows = jdbcTemplate.query("""
select mode, governed_collection, active_generation
from aihr_knowledge_rollout_scope
where tenant_id = ? and knowledge_id = ? limit 1
""", (rs, rowNum) -> new ModeGeneration(rs.getString("mode"),
rs.getString("governed_collection"), rs.getLong("active_generation")), tenantId, knowledgeId);
return rows.isEmpty() ? legacyDefault() : rows.get(0);
} catch (DataAccessException ignored) {
// Compatibility JAR remains fail-open to the existing legacy corpus before schema installation.
return legacyDefault();
}
}
@Transactional(rollbackFor = Exception.class)
public RolloutScope transition(String tenantId, long knowledgeId, long reviewerId, TransitionCommand command) {
if (reviewerId <= 0 || command == null || command.reason() == null || command.reason().trim().isEmpty()) {
throw new ServiceException("A signed-in reviewer and transition reason are required", HttpStatus.BAD_REQUEST);
}
String target = normalizeMode(command.targetMode());
RolloutScope current = requireScope(tenantId, knowledgeId);
if (current.mode().equals(target)) {
return current;
}
if (!canTransition(current.mode(), target)) {
throw new ServiceException("Unsupported rollout transition: " + current.mode() + " -> " + target,
HttpStatus.CONFLICT);
}
if ("ENFORCED".equals(target)) {
throw new ServiceException(
"ENFORCED activation requires a passed shadow gate; use the generation activation endpoint",
HttpStatus.CONFLICT);
}
if ("ENFORCED".equals(target) && current.enforcementBlockers() > 0) {
throw new ServiceException("Space still has " + current.enforcementBlockers()
+ " ready attachment(s) without a published governed vector", HttpStatus.CONFLICT);
}
long generation = command.activeGeneration() == null
? current.activeGeneration() : command.activeGeneration();
if (generation <= 0) {
throw new ServiceException("Active generation must be positive", HttpStatus.BAD_REQUEST);
}
String reason = command.reason().trim();
jdbcTemplate.update("""
insert into aihr_knowledge_rollout_scope
(tenant_id, knowledge_id, mode, governed_collection, active_generation,
candidate_generation, updated_by, reason, create_time, update_time)
values (?, ?, ?, ?, ?, null, ?, ?, now(), now())
on duplicate key update mode = values(mode), governed_collection = values(governed_collection),
active_generation = values(active_generation), candidate_generation = null,
updated_by = values(updated_by), reason = values(reason), update_time = now()
""", tenantId, knowledgeId, target, current.governedCollection(), generation,
reviewerId, reason);
jdbcTemplate.update("""
insert into aihr_knowledge_rollout_transition
(tenant_id, knowledge_id, from_mode, to_mode, active_generation,
reviewer_id, reason, readiness_json, create_time)
values (?, ?, ?, ?, ?, ?, ?, ?, now())
""", tenantId, knowledgeId, current.mode(), target, generation, reviewerId, reason,
readinessJson(current));
return requireScope(tenantId, knowledgeId);
}
static boolean canTransition(String fromMode, String toMode) {
if ("LEGACY".equals(fromMode)) return "SHADOW".equals(toMode);
if ("SHADOW".equals(fromMode)) return Set.of("LEGACY", "ENFORCED").contains(toMode);
return "ENFORCED".equals(fromMode) && Set.of("LEGACY", "SHADOW").contains(toMode);
}
private RolloutScope requireScope(String tenantId, long knowledgeId) {
return list(tenantId).stream().filter(row -> row.knowledgeId() == knowledgeId).findFirst()
.orElseThrow(() -> new ServiceException("Knowledge space does not exist", HttpStatus.NOT_FOUND));
}
private String readinessJson(RolloutScope scope) {
try {
return objectMapper.writeValueAsString(new Readiness(scope.readyAttachments(),
scope.enforcementBlockers(), scope.enforcementBlockers() == 0));
} catch (JsonProcessingException ex) {
throw new ServiceException("Failed to persist rollout readiness evidence");
}
}
private static String normalizeMode(String value) {
String mode = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
if (!MODES.contains(mode)) {
throw new ServiceException("Rollout mode must be LEGACY, SHADOW or ENFORCED", HttpStatus.BAD_REQUEST);
}
return mode;
}
private static ModeGeneration legacyDefault() {
return new ModeGeneration("LEGACY", DEFAULT_GOVERNED_COLLECTION, 1L);
}
private static String rolloutModeSql(String tenantExpression, String knowledgeExpression) {
return "coalesce((select rollout_scope.mode from aihr_knowledge_rollout_scope rollout_scope " +
"where rollout_scope.tenant_id = " + tenantExpression + " " +
"and rollout_scope.knowledge_id = " + knowledgeExpression + " limit 1), 'LEGACY')";
}
private static String publishedFragmentExistsSql(String fragmentAlias) {
return "exists (select 1 from aihr_chunk_revision governed_chunk " +
"join aihr_data_asset governed_asset on governed_asset.tenant_id = governed_chunk.tenant_id " +
"and governed_asset.id = governed_chunk.asset_id " +
"and coalesce(governed_asset.published_version_id, governed_asset.current_version_id) = governed_chunk.version_id " +
"join aihr_dataset_membership governed_dataset on governed_dataset.tenant_id = governed_chunk.tenant_id " +
"and governed_dataset.version_id = governed_chunk.version_id " +
"and governed_dataset.dataset_code = 'production' and governed_dataset.status = 'ACTIVE' " +
"where governed_chunk.tenant_id = " + fragmentAlias + ".tenant_id " +
"and governed_chunk.published_fragment_id = " + fragmentAlias + ".id " +
"and governed_asset.lifecycle_status = 'PUBLISHED' " +
"and governed_asset.trust_level = 'HUMAN_VERIFIED' " +
"and (governed_asset.effective_from is null or governed_asset.effective_from <= current_date()) " +
"and (governed_asset.effective_to is null or governed_asset.effective_to >= current_date()))";
}
public record TransitionCommand(String targetMode, Long activeGeneration, String reason) {
}
public record ModeGeneration(String mode, String governedCollection, long activeGeneration) {
}
public record RolloutScope(long knowledgeId, String spaceCode, String spaceName, String mode,
String governedCollection, long activeGeneration, Long candidateGeneration,
Long updatedBy, String reason, java.time.LocalDateTime updatedTime,
int readyAttachments, int enforcementBlockers) {
}
private record Readiness(int readyAttachments, int enforcementBlockers, boolean ready) {
}
}
@@ -63,7 +63,8 @@ public class AihrKnowledgeSemanticAnalysisService {
List<PendingVersion> versions = jdbcTemplate.query("""
select v.id, v.tenant_id, v.asset_id, v.redacted_content, v.semantic_retry_count
from aihr_data_version v
join aihr_data_asset a on a.tenant_id = v.tenant_id and a.current_version_id = v.id
join aihr_data_asset a on a.tenant_id = v.tenant_id
and v.id = coalesce(a.candidate_version_id, a.current_version_id)
where v.semantic_analysis_status in ('PENDING','FAILED')
and v.privacy_status in ('CLEAN','REDACTED')
and v.redacted_content is not null
@@ -199,7 +200,8 @@ public class AihrKnowledgeSemanticAnalysisService {
List<SemanticCandidate> candidates = jdbcTemplate.query("""
select a.id asset_id, v.id version_id, v.semantic_embedding_json
from aihr_data_asset a
join aihr_data_version v on v.tenant_id = a.tenant_id and v.id = a.current_version_id
join aihr_data_version v on v.tenant_id = a.tenant_id
and v.id = coalesce(a.candidate_version_id, a.current_version_id)
where a.tenant_id = ? and a.id <> ?
and a.lifecycle_status in ('REVIEW_PENDING','APPROVED','PUBLISHED')
and v.semantic_analysis_status = 'COMPLETE'
@@ -0,0 +1,434 @@
package org.dromara.aihr.knowledge.quality;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.time.LocalDateTime;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class AihrKnowledgeShadowRolloutService {
static final int MIN_SAMPLES = 20;
static final double MIN_TOP1_AGREEMENT = 0.90d;
static final double MIN_OVERLAP_AT_5 = 0.80d;
static final double MAX_EMPTY_RATE = 0.01d;
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper;
@Transactional
public GenerationBuild startBuild(String tenantId, long knowledgeId, long reviewerId, BuildCommand command) {
if (reviewerId <= 0 || command == null || command.reason() == null || command.reason().isBlank()) {
throw new ServiceException("A signed-in reviewer and rebuild reason are required", HttpStatus.BAD_REQUEST);
}
RolloutTarget scope = lockScope(tenantId, knowledgeId);
if (!"SHADOW".equals(scope.mode())) {
throw new ServiceException("Candidate generation can only be built in SHADOW mode", HttpStatus.CONFLICT);
}
if (scope.candidateGeneration() != null) {
return buildStatus(tenantId, knowledgeId, scope.candidateGeneration());
}
Long maxGeneration = jdbcTemplate.queryForObject("""
select coalesce(max(generation), 0) from aihr_knowledge_generation
where tenant_id = ? and knowledge_id = ?
""", Long.class, tenantId, knowledgeId);
long generation = Math.max(scope.activeGeneration() + 1, (maxGeneration == null ? 0 : maxGeneration) + 1);
String collection = scope.collection();
jdbcTemplate.update("""
insert into aihr_knowledge_generation
(tenant_id, knowledge_id, generation, collection_name, base_generation, status,
version_count, point_count, created_by, create_time)
values (?, ?, ?, ?, ?, 'BUILDING', 0, 0, ?, now())
""", tenantId, knowledgeId, generation, collection, scope.activeGeneration(), reviewerId);
int jobs = jdbcTemplate.update("""
insert into aihr_index_outbox
(tenant_id, asset_id, version_id, operation, target_collection, index_generation,
payload_json, status, attempt_count, next_attempt_time, create_time, update_time)
select a.tenant_id, a.id, a.published_version_id, 'UPSERT', ?, ?,
json_object('docId', a.doc_id, 'assetId', a.id, 'versionId', a.published_version_id,
'generation', ?), 'PENDING', 0, now(), now(), now()
from aihr_data_asset a
where a.tenant_id = ? and a.knowledge_id = ? and a.lifecycle_status = 'PUBLISHED'
and a.trust_level = 'HUMAN_VERIFIED' and a.published_version_id is not null
and not exists (select 1 from aihr_index_outbox o where o.tenant_id = a.tenant_id
and o.asset_id = a.id and o.version_id = a.published_version_id
and o.index_generation = ? and o.operation = 'UPSERT')
""", collection, generation, generation, tenantId, knowledgeId, generation);
jdbcTemplate.update("""
update aihr_knowledge_rollout_scope
set candidate_generation = ?, updated_by = ?, reason = ?, update_time = now()
where tenant_id = ? and knowledge_id = ?
""", generation, reviewerId, command.reason().trim(), tenantId, knowledgeId);
return new GenerationBuild(knowledgeId, generation, collection, "BUILDING", jobs, 0, 0);
}
@Transactional
public GenerationBuild refreshBuild(String tenantId, long knowledgeId) {
RolloutTarget scope = lockScope(tenantId, knowledgeId);
if (scope.candidateGeneration() == null) {
throw new ServiceException("No candidate generation exists", HttpStatus.NOT_FOUND);
}
long generation = scope.candidateGeneration();
int expected = count("""
select count(*) from aihr_data_asset where tenant_id = ? and knowledge_id = ?
and lifecycle_status = 'PUBLISHED' and trust_level = 'HUMAN_VERIFIED'
and published_version_id is not null
""", tenantId, knowledgeId);
int included = count("""
select count(*) from aihr_generation_version where tenant_id = ? and knowledge_id = ?
and generation = ? and status = 'INCLUDED'
""", tenantId, knowledgeId, generation);
int failed = count("""
select count(*) from aihr_index_outbox o
join aihr_data_asset a on a.tenant_id = o.tenant_id and a.id = o.asset_id
where o.tenant_id = ? and a.knowledge_id = ? and o.index_generation = ?
and o.status in ('FAILED','DEAD_LETTER')
""", tenantId, knowledgeId, generation);
int pending = count("""
select count(*) from aihr_index_outbox o
join aihr_data_asset a on a.tenant_id = o.tenant_id and a.id = o.asset_id
where o.tenant_id = ? and a.knowledge_id = ? and o.index_generation = ?
and o.status in ('PENDING','PROCESSING')
""", tenantId, knowledgeId, generation);
String status = failed > 0 ? "FAILED" : (pending == 0 && included == expected ? "READY" : "BUILDING");
jdbcTemplate.update("""
update aihr_knowledge_generation
set status = ?, ready_time = case when ? = 'READY' then coalesce(ready_time, now()) else ready_time end,
last_error = case when ? = 'FAILED' then 'INDEX_OUTBOX_FAILURE' else null end
where tenant_id = ? and knowledge_id = ? and generation = ?
""", status, status, status, tenantId, knowledgeId, generation);
GenerationBuild result = buildStatus(tenantId, knowledgeId, generation);
return new GenerationBuild(result.knowledgeId(), result.generation(), result.collection(), status,
expected, included, failed + pending);
}
@Transactional
public ShadowComparison compare(String tenantId, long knowledgeId, ComparisonCommand command) {
if (command == null || command.queryText() == null || command.queryText().isBlank()) {
throw new ServiceException("A non-empty shadow query is required", HttpStatus.BAD_REQUEST);
}
RolloutTarget scope = requireScope(tenantId, knowledgeId, false);
if (!"SHADOW".equals(scope.mode()) || scope.candidateGeneration() == null) {
throw new ServiceException("Shadow comparison requires a candidate generation", HttpStatus.CONFLICT);
}
String query = command.queryText().trim().substring(0, Math.min(1000, command.queryText().trim().length()));
long started = System.nanoTime();
List<Long> legacy = jdbcTemplate.queryForList("""
select f.id from aihr_knowledge_fragment f
join aihr_knowledge_attach a on a.tenant_id = f.tenant_id
and a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id and a.status = 2
where f.tenant_id = ? and f.knowledge_id = ? and f.content like concat('%', ?, '%')
order by f.id limit 5
""", Long.class, tenantId, knowledgeId, query);
List<Long> governed = jdbcTemplate.queryForList("""
select c.published_fragment_id
from aihr_chunk_revision c
join aihr_data_asset a on a.tenant_id = c.tenant_id and a.id = c.asset_id
and a.published_version_id = c.version_id
join aihr_dataset_membership d on d.tenant_id = c.tenant_id and d.version_id = c.version_id
and d.dataset_code = 'production' and d.status = 'ACTIVE'
where c.tenant_id = ? and a.knowledge_id = ? and a.lifecycle_status = 'PUBLISHED'
and c.published_fragment_id is not null and c.content like concat('%', ?, '%')
order by c.chunk_index, c.id limit 5
""", Long.class, tenantId, knowledgeId, query);
long latencyMs = Math.max(0, (System.nanoTime() - started) / 1_000_000L);
boolean top1 = !legacy.isEmpty() && !governed.isEmpty() && legacy.get(0).equals(governed.get(0));
Set<Long> intersection = new LinkedHashSet<>(legacy);
intersection.retainAll(governed);
double overlap = Math.max(legacy.size(), governed.size()) == 0 ? 1d
: (double) intersection.size() / Math.max(legacy.size(), governed.size());
String queryHash = AihrKnowledgeLifecycleService.sha256(query);
jdbcTemplate.update("""
insert into aihr_shadow_comparison
(tenant_id, knowledge_id, generation, query_sha256, query_text,
legacy_result_json, governed_result_json, legacy_top1_fragment_id,
governed_top1_fragment_id, top1_agreement, overlap_at_5, governed_empty,
latency_ms, source, create_time)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now())
on duplicate key update legacy_result_json = values(legacy_result_json),
governed_result_json = values(governed_result_json),
legacy_top1_fragment_id = values(legacy_top1_fragment_id),
governed_top1_fragment_id = values(governed_top1_fragment_id),
top1_agreement = values(top1_agreement), overlap_at_5 = values(overlap_at_5),
governed_empty = values(governed_empty), latency_ms = values(latency_ms),
source = values(source), create_time = now()
""", tenantId, knowledgeId, scope.candidateGeneration(), queryHash, query,
json(legacy), json(governed), legacy.isEmpty() ? null : legacy.get(0),
governed.isEmpty() ? null : governed.get(0), top1, overlap, governed.isEmpty(), latencyMs,
command.source() == null || command.source().isBlank() ? "MANUAL_SAMPLE" : command.source());
return new ShadowComparison(knowledgeId, scope.candidateGeneration(), queryHash, legacy, governed,
top1, overlap, governed.isEmpty(), latencyMs);
}
@Transactional
public ActivationGate evaluateGate(String tenantId, long knowledgeId, long reviewerId) {
if (reviewerId <= 0) throw new ServiceException("A signed-in reviewer is required", HttpStatus.BAD_REQUEST);
RolloutTarget scope = requireScope(tenantId, knowledgeId, false);
if (!"SHADOW".equals(scope.mode()) || scope.candidateGeneration() == null) {
throw new ServiceException("Activation gate requires SHADOW mode and a candidate generation",
HttpStatus.CONFLICT);
}
long generation = scope.candidateGeneration();
GateMetrics metrics = jdbcTemplate.queryForObject("""
select count(*) sample_count, coalesce(avg(top1_agreement), 0) top1_rate,
coalesce(avg(overlap_at_5), 0) overlap_avg, coalesce(avg(governed_empty), 0) empty_rate
from aihr_shadow_comparison where tenant_id = ? and knowledge_id = ? and generation = ?
""", (rs, rowNum) -> new GateMetrics(rs.getInt("sample_count"), rs.getDouble("top1_rate"),
rs.getDouble("overlap_avg"), rs.getDouble("empty_rate")), tenantId, knowledgeId, generation);
int blockers = count("""
select count(*) from aihr_knowledge_attach a where a.tenant_id = ? and a.knowledge_id = ? and a.status = 2
and not exists (select 1 from aihr_data_asset governed where governed.tenant_id = a.tenant_id
and governed.attachment_id = a.id and governed.lifecycle_status = 'PUBLISHED'
and governed.trust_level = 'HUMAN_VERIFIED' and governed.index_status = 'READY')
""", tenantId, knowledgeId);
int critical = count("""
select count(*) from aihr_quality_issue q join aihr_data_asset a
on a.tenant_id = q.tenant_id and a.id = q.asset_id
where q.tenant_id = ? and a.knowledge_id = ? and q.status = 'OPEN'
and (q.gate_type = 'HARD' or q.severity = 'CRITICAL')
""", tenantId, knowledgeId);
int deadLetters = count("""
select count(*) from aihr_index_outbox o join aihr_data_asset a
on a.tenant_id = o.tenant_id and a.id = o.asset_id
where o.tenant_id = ? and a.knowledge_id = ? and o.index_generation = ? and o.status = 'DEAD_LETTER'
""", tenantId, knowledgeId, generation);
String generationStatus = jdbcTemplate.queryForObject("""
select status from aihr_knowledge_generation
where tenant_id = ? and knowledge_id = ? and generation = ?
""", String.class, tenantId, knowledgeId, generation);
GateMetrics safe = metrics == null ? new GateMetrics(0, 0, 0, 1) : metrics;
boolean passed = "READY".equals(generationStatus) && safe.sampleCount() >= MIN_SAMPLES
&& safe.top1Rate() >= MIN_TOP1_AGREEMENT && safe.overlapAvg() >= MIN_OVERLAP_AT_5
&& safe.emptyRate() <= MAX_EMPTY_RATE && blockers == 0 && critical == 0 && deadLetters == 0;
GateThresholds thresholds = new GateThresholds(MIN_SAMPLES, MIN_TOP1_AGREEMENT,
MIN_OVERLAP_AT_5, MAX_EMPTY_RATE);
GateEvidence evidence = new GateEvidence(blockers, critical, deadLetters, generationStatus);
KeyHolder key = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement statement = connection.prepareStatement("""
insert into aihr_activation_gate
(tenant_id, knowledge_id, candidate_generation, status, sample_count,
top1_agreement_rate, overlap_at_5_avg, governed_empty_rate, blocking_asset_count,
open_critical_issue_count, dead_letter_count, generation_status,
threshold_json, evidence_json, evaluated_by, create_time)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now())
""", Statement.RETURN_GENERATED_KEYS);
statement.setString(1, tenantId);
statement.setLong(2, knowledgeId);
statement.setLong(3, generation);
statement.setString(4, passed ? "PASSED" : "BLOCKED");
statement.setInt(5, safe.sampleCount());
statement.setDouble(6, safe.top1Rate());
statement.setDouble(7, safe.overlapAvg());
statement.setDouble(8, safe.emptyRate());
statement.setInt(9, blockers);
statement.setInt(10, critical);
statement.setInt(11, deadLetters);
statement.setString(12, generationStatus == null ? "MISSING" : generationStatus);
statement.setString(13, json(thresholds));
statement.setString(14, json(evidence));
statement.setLong(15, reviewerId);
return statement;
}, key);
return new ActivationGate(key.getKey().longValue(), knowledgeId, generation,
passed ? "PASSED" : "BLOCKED", safe.sampleCount(), safe.top1Rate(), safe.overlapAvg(),
safe.emptyRate(), evidence, thresholds);
}
@Transactional
public ActivationResult activate(String tenantId, long knowledgeId, long reviewerId, ActivationCommand command) {
if (reviewerId <= 0 || command == null || command.gateId() <= 0
|| command.reason() == null || command.reason().isBlank()) {
throw new ServiceException("A passed gate, signed-in reviewer and reason are required", HttpStatus.BAD_REQUEST);
}
RolloutTarget scope = lockScope(tenantId, knowledgeId);
GateRef gate = requirePassedGate(tenantId, knowledgeId, command.gateId(), scope.candidateGeneration());
long from = scope.activeGeneration();
long to = gate.generation();
jdbcTemplate.update("""
update aihr_knowledge_generation set status = 'RETIRED', retire_after = date_add(now(), interval 7 day)
where tenant_id = ? and knowledge_id = ? and generation = ?
""", tenantId, knowledgeId, from);
jdbcTemplate.update("""
update aihr_knowledge_generation
set status = 'ACTIVE', activated_by = ?, activated_time = now(), retire_after = null
where tenant_id = ? and knowledge_id = ? and generation = ? and status = 'READY'
""", reviewerId, tenantId, knowledgeId, to);
jdbcTemplate.update("""
update aihr_knowledge_rollout_scope
set mode = 'ENFORCED', active_generation = ?, candidate_generation = null,
updated_by = ?, reason = ?, update_time = now()
where tenant_id = ? and knowledge_id = ? and mode = 'SHADOW'
""", to, reviewerId, command.reason().trim(), tenantId, knowledgeId);
jdbcTemplate.update("""
insert into aihr_generation_activation
(tenant_id, knowledge_id, from_generation, to_generation, gate_id, action,
reason, reviewer_id, rollback_deadline, create_time)
values (?, ?, ?, ?, ?, 'ACTIVATE', ?, ?, date_add(now(), interval 7 day), now())
""", tenantId, knowledgeId, from, to, gate.id(), command.reason().trim(), reviewerId);
return new ActivationResult(knowledgeId, from, to, "ENFORCED", LocalDateTime.now().plusDays(7));
}
@Transactional
public ActivationResult rollback(String tenantId, long knowledgeId, long reviewerId, RollbackCommand command) {
if (reviewerId <= 0 || command == null || command.reason() == null || command.reason().isBlank()) {
throw new ServiceException("A signed-in reviewer and rollback reason are required", HttpStatus.BAD_REQUEST);
}
RolloutTarget scope = lockScope(tenantId, knowledgeId);
ActivationWindow window = jdbcTemplate.query("""
select from_generation, to_generation, gate_id, rollback_deadline
from aihr_generation_activation
where tenant_id = ? and knowledge_id = ? and action = 'ACTIVATE'
order by id desc limit 1 for update
""", rs -> rs.next() ? new ActivationWindow(rs.getLong("from_generation"),
rs.getLong("to_generation"), rs.getLong("gate_id"),
rs.getObject("rollback_deadline", LocalDateTime.class)) : null, tenantId, knowledgeId);
if (window == null || window.toGeneration() != scope.activeGeneration()
|| window.rollbackDeadline() == null || LocalDateTime.now().isAfter(window.rollbackDeadline())) {
throw new ServiceException("Generation rollback window is unavailable or expired", HttpStatus.CONFLICT);
}
jdbcTemplate.update("""
update aihr_knowledge_generation set status = 'RETIRED', retire_after = now()
where tenant_id = ? and knowledge_id = ? and generation = ?
""", tenantId, knowledgeId, window.toGeneration());
jdbcTemplate.update("""
update aihr_knowledge_generation set status = 'ACTIVE', retire_after = null
where tenant_id = ? and knowledge_id = ? and generation = ?
""", tenantId, knowledgeId, window.fromGeneration());
jdbcTemplate.update("""
update aihr_knowledge_rollout_scope
set mode = 'SHADOW', active_generation = ?, candidate_generation = ?,
updated_by = ?, reason = ?, update_time = now()
where tenant_id = ? and knowledge_id = ?
""", window.fromGeneration(), window.toGeneration(), reviewerId,
command.reason().trim(), tenantId, knowledgeId);
jdbcTemplate.update("""
insert into aihr_generation_activation
(tenant_id, knowledge_id, from_generation, to_generation, gate_id, action,
reason, reviewer_id, create_time)
values (?, ?, ?, ?, ?, 'ROLLBACK', ?, ?, now())
""", tenantId, knowledgeId, window.toGeneration(), window.fromGeneration(), window.gateId(),
command.reason().trim(), reviewerId);
return new ActivationResult(knowledgeId, window.toGeneration(), window.fromGeneration(),
"SHADOW", null);
}
private GenerationBuild buildStatus(String tenantId, long knowledgeId, long generation) {
List<GenerationBuild> rows = jdbcTemplate.query("""
select generation, collection_name, status, version_count, point_count
from aihr_knowledge_generation where tenant_id = ? and knowledge_id = ? and generation = ?
""", (rs, rowNum) -> new GenerationBuild(knowledgeId, rs.getLong("generation"),
rs.getString("collection_name"), rs.getString("status"), rs.getInt("version_count"),
rs.getInt("point_count"), 0), tenantId, knowledgeId, generation);
if (rows.isEmpty()) throw new ServiceException("Generation does not exist", HttpStatus.NOT_FOUND);
return rows.get(0);
}
private RolloutTarget lockScope(String tenantId, long knowledgeId) {
return requireScope(tenantId, knowledgeId, true);
}
private RolloutTarget requireScope(String tenantId, long knowledgeId, boolean lock) {
List<RolloutTarget> rows = jdbcTemplate.query("""
select mode, governed_collection, active_generation, candidate_generation
from aihr_knowledge_rollout_scope where tenant_id = ? and knowledge_id = ? %s
""".formatted(lock ? "for update" : ""), (rs, rowNum) -> new RolloutTarget(
rs.getString("mode"), rs.getString("governed_collection"), rs.getLong("active_generation"),
rs.getObject("candidate_generation", Long.class)), tenantId, knowledgeId);
if (rows.isEmpty()) throw new ServiceException("Rollout scope must be initialized first", HttpStatus.NOT_FOUND);
return rows.get(0);
}
private GateRef requirePassedGate(String tenantId, long knowledgeId, long gateId, Long candidateGeneration) {
if (candidateGeneration == null) throw new ServiceException("Candidate generation is missing", HttpStatus.CONFLICT);
List<GateRef> rows = jdbcTemplate.query("""
select id, candidate_generation from aihr_activation_gate
where tenant_id = ? and knowledge_id = ? and id = ? and status = 'PASSED'
and candidate_generation = ? limit 1
""", (rs, rowNum) -> new GateRef(rs.getLong("id"), rs.getLong("candidate_generation")),
tenantId, knowledgeId, gateId, candidateGeneration);
if (rows.isEmpty()) throw new ServiceException("Passed activation gate does not match candidate generation",
HttpStatus.CONFLICT);
return rows.get(0);
}
private int count(String sql, Object... args) {
Integer value = jdbcTemplate.queryForObject(sql, Integer.class, args);
return value == null ? 0 : value;
}
private String json(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException ex) {
throw new ServiceException("Failed to serialize rollout evidence").setDetailMessage(ex.getMessage());
}
}
public record BuildCommand(String reason) {
}
public record ComparisonCommand(String queryText, String source) {
}
public record ActivationCommand(long gateId, String reason) {
}
public record RollbackCommand(String reason) {
}
public record GenerationBuild(long knowledgeId, long generation, String collection, String status,
int expectedVersions, int includedVersions, int outstandingJobs) {
}
public record ShadowComparison(long knowledgeId, long generation, String querySha256,
List<Long> legacyFragmentIds, List<Long> governedFragmentIds,
boolean top1Agreement, double overlapAt5, boolean governedEmpty,
long latencyMs) {
}
public record ActivationGate(long gateId, long knowledgeId, long candidateGeneration, String status,
int sampleCount, double top1AgreementRate, double overlapAt5Avg,
double governedEmptyRate, GateEvidence evidence, GateThresholds thresholds) {
}
public record GateEvidence(int blockingAssets, int openCriticalIssues, int deadLetters,
String generationStatus) {
}
public record GateThresholds(int minSamples, double minTop1Agreement,
double minOverlapAt5, double maxGovernedEmptyRate) {
}
public record ActivationResult(long knowledgeId, long fromGeneration, long toGeneration,
String mode, LocalDateTime rollbackDeadline) {
}
private record RolloutTarget(String mode, String collection, long activeGeneration,
Long candidateGeneration) {
}
private record GateMetrics(int sampleCount, double top1Rate, double overlapAvg, double emptyRate) {
}
private record GateRef(long id, long generation) {
}
private record ActivationWindow(long fromGeneration, long toGeneration, long gateId,
LocalDateTime rollbackDeadline) {
}
}
@@ -0,0 +1,305 @@
package org.dromara.aihr.knowledge.quality;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class AihrKnowledgeVersionGovernanceService {
public static final String DIFF_POLICY_VERSION = "chunk-diff-v1";
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper;
private final AihrKnowledgeLifecycleService lifecycleService;
@Value("${aihr.qdrant.governed-collection:${AIHR_QDRANT_GOVERNED_COLLECTION:aihr_knowledge_governed_v1}}")
private String governedCollection = AihrKnowledgeRolloutService.DEFAULT_GOVERNED_COLLECTION;
@Transactional
public VersionDiff compare(String tenantId, long assetId, long fromVersionId, long toVersionId, long reviewerId) {
if (fromVersionId <= 0 || toVersionId <= 0 || fromVersionId == toVersionId) {
throw new ServiceException("Two different versions are required", HttpStatus.BAD_REQUEST);
}
AihrKnowledgeLifecycleService.VersionDetail from = lifecycleService.versionDetail(
tenantId, assetId, fromVersionId);
AihrKnowledgeLifecycleService.VersionDetail to = lifecycleService.versionDetail(
tenantId, assetId, toVersionId);
Map<Integer, AihrKnowledgeLifecycleService.VersionChunk> left = byIndex(from.chunks());
Map<Integer, AihrKnowledgeLifecycleService.VersionChunk> right = byIndex(to.chunks());
int maxIndex = Math.max(left.keySet().stream().mapToInt(Integer::intValue).max().orElse(0),
right.keySet().stream().mapToInt(Integer::intValue).max().orElse(0));
List<ChunkChange> changes = new ArrayList<>();
int added = 0;
int removed = 0;
int modified = 0;
int unchanged = 0;
for (int index = 1; index <= maxIndex; index++) {
var before = left.get(index);
var after = right.get(index);
String type;
if (before == null) {
type = "ADDED";
added++;
} else if (after == null) {
type = "REMOVED";
removed++;
} else if (before.contentSha256().equals(after.contentSha256())) {
type = "UNCHANGED";
unchanged++;
} else {
type = "MODIFIED";
modified++;
}
if (!"UNCHANGED".equals(type)) {
changes.add(new ChunkChange(index, type,
before == null ? null : before.id(), after == null ? null : after.id(),
before == null ? null : before.contentSha256(), after == null ? null : after.contentSha256(),
before == null ? null : truncate(before.content(), 500),
after == null ? null : truncate(after.content(), 500)));
}
}
DiffSummary summary = new DiffSummary(!from.contentSha256().equals(to.contentSha256()),
safeLength(from.redactedContent()), safeLength(to.redactedContent()), added, removed, modified, unchanged);
jdbcTemplate.update("""
insert into aihr_version_diff
(tenant_id, asset_id, from_version_id, to_version_id, policy_version,
summary_json, chunk_diff_json, created_by, create_time)
values (?, ?, ?, ?, ?, ?, ?, ?, now())
on duplicate key update summary_json = values(summary_json), chunk_diff_json = values(chunk_diff_json),
created_by = values(created_by), create_time = now()
""", tenantId, assetId, fromVersionId, toVersionId, DIFF_POLICY_VERSION,
json(summary), json(changes), reviewerId > 0 ? reviewerId : null);
return new VersionDiff(assetId, fromVersionId, toVersionId, DIFF_POLICY_VERSION,
summary, List.copyOf(changes));
}
@Transactional
public RollbackResult rollback(String tenantId, long assetId, long reviewerId, RollbackCommand command) {
if (reviewerId <= 0 || command == null || command.targetVersionId() <= 0
|| command.reason() == null || command.reason().isBlank()) {
throw new ServiceException("A target version, signed-in reviewer and reason are required",
HttpStatus.BAD_REQUEST);
}
RollbackAsset asset = lockAsset(tenantId, assetId);
if (!"PUBLISHED".equals(asset.lifecycleStatus()) || asset.publishedVersionId() == null) {
throw new ServiceException("Only a published asset can be rolled back", HttpStatus.CONFLICT);
}
if (asset.candidateVersionId() != null) {
throw new ServiceException("Withdraw or publish the current candidate before rollback", HttpStatus.CONFLICT);
}
if (asset.publishedVersionId() == command.targetVersionId()) {
return new RollbackResult(assetId, asset.publishedVersionId(), command.targetVersionId(),
asset.generation(), "UNCHANGED", 0L);
}
TargetVersion target = targetVersion(tenantId, assetId, command.targetVersionId());
if (!Set.of("SUPERSEDED", "WITHDRAWN", "PUBLISHED").contains(target.status())
|| target.publishedFragments() <= 0) {
throw new ServiceException("Rollback target was never published or no longer has immutable fragments",
HttpStatus.CONFLICT);
}
Integer membership = jdbcTemplate.queryForObject("""
select count(*) from aihr_dataset_membership
where tenant_id = ? and version_id = ? and dataset_code = 'production'
""", Integer.class, tenantId, command.targetVersionId());
if (membership == null || membership == 0) {
throw new ServiceException("Rollback target has no audited production membership", HttpStatus.CONFLICT);
}
long fromVersionId = asset.publishedVersionId();
jdbcTemplate.update("""
update aihr_dataset_membership set status = 'DISABLED'
where tenant_id = ? and version_id = ? and dataset_code = 'production'
""", tenantId, fromVersionId);
jdbcTemplate.update("""
update aihr_dataset_membership set status = 'ACTIVE'
where tenant_id = ? and version_id = ? and dataset_code = 'production'
""", tenantId, command.targetVersionId());
jdbcTemplate.update("""
update aihr_data_version set version_status = 'SUPERSEDED'
where tenant_id = ? and id = ? and version_status = 'PUBLISHED'
""", tenantId, fromVersionId);
jdbcTemplate.update("""
update aihr_data_version set version_status = 'PUBLISHED'
where tenant_id = ? and id = ?
""", tenantId, command.targetVersionId());
jdbcTemplate.update("""
update aihr_knowledge_claim set status = 'DEPRECATED'
where tenant_id = ? and version_id = ? and status = 'PUBLISHED'
""", tenantId, fromVersionId);
jdbcTemplate.update("""
update aihr_knowledge_claim set status = 'PUBLISHED'
where tenant_id = ? and version_id = ? and status in ('CANDIDATE','DEPRECATED')
""", tenantId, command.targetVersionId());
jdbcTemplate.update("""
update aihr_data_asset
set published_version_id = ?, current_version_id = ?, candidate_version_id = null,
lifecycle_status = 'PUBLISHED', trust_level = 'HUMAN_VERIFIED',
content_sha256 = ?, index_status = 'PENDING', update_time = now()
where tenant_id = ? and id = ? and published_version_id = ?
""", command.targetVersionId(), command.targetVersionId(), target.contentSha256(),
tenantId, assetId, fromVersionId);
jdbcTemplate.update("""
insert into aihr_index_outbox
(tenant_id, asset_id, version_id, operation, target_collection, index_generation,
payload_json, status, attempt_count, next_attempt_time, create_time, update_time)
values (?, ?, ?, 'UPSERT', ?, ?, json_object('docId', ?, 'assetId', ?, 'versionId', ?,
'generation', ?), 'PENDING', 0, now(), now(), now())
""", tenantId, assetId, command.targetVersionId(), asset.collection(), asset.generation(),
asset.docId(), assetId, command.targetVersionId(), asset.generation());
jdbcTemplate.update("""
insert into aihr_review_decision
(tenant_id, asset_id, version_id, decision, reason, reviewer_id, reviewer_type, reviewed_time)
values (?, ?, ?, 'ROLLBACK', ?, ?, 'HUMAN', now())
""", tenantId, assetId, command.targetVersionId(), command.reason().trim(), reviewerId);
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement statement = connection.prepareStatement("""
insert into aihr_version_rollback
(tenant_id, asset_id, from_version_id, to_version_id, from_generation, to_generation,
reason, reviewer_id, create_time)
values (?, ?, ?, ?, ?, ?, ?, ?, now())
""", Statement.RETURN_GENERATED_KEYS);
statement.setString(1, tenantId);
statement.setLong(2, assetId);
statement.setLong(3, fromVersionId);
statement.setLong(4, command.targetVersionId());
statement.setLong(5, asset.generation());
statement.setLong(6, asset.generation());
statement.setString(7, command.reason().trim());
statement.setLong(8, reviewerId);
return statement;
}, keyHolder);
Number rollbackKey = keyHolder.getKey();
return new RollbackResult(assetId, fromVersionId, command.targetVersionId(), asset.generation(),
"PENDING_INDEX", rollbackKey == null ? 0L : rollbackKey.longValue());
}
public List<GenerationSummary> generations(String tenantId, long knowledgeId) {
return jdbcTemplate.query("""
select generation, collection_name, base_generation, status, version_count, point_count,
manifest_sha256, create_time, ready_time, activated_time, retire_after, last_error
from aihr_knowledge_generation
where tenant_id = ? and knowledge_id = ? order by generation desc
""", (rs, rowNum) -> new GenerationSummary(rs.getLong("generation"),
rs.getString("collection_name"), rs.getObject("base_generation", Long.class),
rs.getString("status"), rs.getInt("version_count"), rs.getInt("point_count"),
rs.getString("manifest_sha256"), rs.getObject("create_time", LocalDateTime.class),
rs.getObject("ready_time", LocalDateTime.class), rs.getObject("activated_time", LocalDateTime.class),
rs.getObject("retire_after", LocalDateTime.class), rs.getString("last_error")), tenantId, knowledgeId);
}
private RollbackAsset lockAsset(String tenantId, long assetId) {
List<LockedAsset> rows = jdbcTemplate.query("""
select doc_id, knowledge_id, lifecycle_status, published_version_id, candidate_version_id
from aihr_data_asset where tenant_id = ? and id = ? for update
""", (rs, rowNum) -> new LockedAsset(rs.getString("doc_id"), rs.getLong("knowledge_id"),
rs.getString("lifecycle_status"), rs.getObject("published_version_id", Long.class),
rs.getObject("candidate_version_id", Long.class)), tenantId, assetId);
if (rows.isEmpty()) throw new ServiceException("Data asset not found", HttpStatus.NOT_FOUND);
LockedAsset asset = rows.get(0);
List<GenerationTarget> scopes = jdbcTemplate.query("""
select governed_collection, active_generation from aihr_knowledge_rollout_scope
where tenant_id = ? and knowledge_id = ? limit 1
""", (rs, rowNum) -> new GenerationTarget(rs.getString("governed_collection"),
rs.getLong("active_generation")), tenantId, asset.knowledgeId());
GenerationTarget scope = scopes.isEmpty()
? new GenerationTarget(governedCollection, 1L) : scopes.get(0);
return new RollbackAsset(asset.docId(), asset.lifecycleStatus(), asset.publishedVersionId(),
asset.candidateVersionId(), scope.collection(), scope.generation());
}
private TargetVersion targetVersion(String tenantId, long assetId, long versionId) {
List<TargetVersion> rows = jdbcTemplate.query("""
select v.version_status, v.content_sha256,
(select count(*) from aihr_chunk_revision c
where c.tenant_id = v.tenant_id and c.version_id = v.id
and c.published_fragment_id is not null) published_fragments
from aihr_data_version v
where v.tenant_id = ? and v.asset_id = ? and v.id = ? limit 1
""", (rs, rowNum) -> new TargetVersion(rs.getString("version_status"),
rs.getString("content_sha256"), rs.getInt("published_fragments")), tenantId, assetId, versionId);
if (rows.isEmpty()) throw new ServiceException("Rollback target does not exist", HttpStatus.NOT_FOUND);
return rows.get(0);
}
private static Map<Integer, AihrKnowledgeLifecycleService.VersionChunk> byIndex(
List<AihrKnowledgeLifecycleService.VersionChunk> chunks) {
Map<Integer, AihrKnowledgeLifecycleService.VersionChunk> result = new LinkedHashMap<>();
chunks.forEach(chunk -> result.put(chunk.chunkIndex(), chunk));
return result;
}
private String json(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException ex) {
throw new ServiceException("Failed to serialize version evidence").setDetailMessage(ex.getMessage());
}
}
private static int safeLength(String value) {
return value == null ? 0 : value.length();
}
private static String truncate(String value, int max) {
if (value == null || value.length() <= max) return value;
return value.substring(0, max);
}
public record VersionDiff(long assetId, long fromVersionId, long toVersionId, String policyVersion,
DiffSummary summary, List<ChunkChange> changes) {
}
public record DiffSummary(boolean contentChanged, int fromCharacters, int toCharacters,
int addedChunks, int removedChunks, int modifiedChunks, int unchangedChunks) {
}
public record ChunkChange(int chunkIndex, String type, Long fromChunkRevisionId, Long toChunkRevisionId,
String fromSha256, String toSha256, String fromPreview, String toPreview) {
}
public record RollbackCommand(long targetVersionId, String reason) {
}
public record RollbackResult(long assetId, long fromVersionId, long toVersionId, long generation,
String indexStatus, long rollbackId) {
}
public record GenerationSummary(long generation, String collection, Long baseGeneration, String status,
int versionCount, int pointCount, String manifestSha256,
LocalDateTime createTime, LocalDateTime readyTime,
LocalDateTime activatedTime, LocalDateTime retireAfter, String lastError) {
}
private record RollbackAsset(String docId, String lifecycleStatus, Long publishedVersionId,
Long candidateVersionId, String collection, long generation) {
}
private record TargetVersion(String status, String contentSha256, int publishedFragments) {
}
private record LockedAsset(String docId, long knowledgeId, String lifecycleStatus,
Long publishedVersionId, Long candidateVersionId) {
}
private record GenerationTarget(String collection, long generation) {
}
}
@@ -7,6 +7,7 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.CitationDetail;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.LocatorSummary;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.TextSegment;
import org.dromara.aihr.knowledge.quality.AihrKnowledgeRolloutService;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
@@ -74,25 +75,7 @@ public class AihrKnowledgeCitationDetailService {
left join aihr_knowledge_fragment_locator l on l.tenant_id = f.tenant_id and l.fragment_id = f.id
and l.attachment_id = a.id
where f.tenant_id = ? and f.id = ? and f.knowledge_id in (%s) and k.status = 'ACTIVE'
and exists (
select 1
from aihr_chunk_revision governed_chunk
join aihr_data_asset governed_asset
on governed_asset.tenant_id = governed_chunk.tenant_id
and governed_asset.id = governed_chunk.asset_id
and governed_asset.current_version_id = governed_chunk.version_id
join aihr_dataset_membership governed_dataset
on governed_dataset.tenant_id = governed_chunk.tenant_id
and governed_dataset.version_id = governed_chunk.version_id
and governed_dataset.dataset_code = 'production'
and governed_dataset.status = 'ACTIVE'
where governed_chunk.tenant_id = f.tenant_id
and governed_chunk.published_fragment_id = f.id
and governed_asset.lifecycle_status = 'PUBLISHED'
and governed_asset.trust_level = 'HUMAN_VERIFIED'
and (governed_asset.effective_from is null or governed_asset.effective_from <= current_date())
and (governed_asset.effective_to is null or governed_asset.effective_to >= current_date())
)
and %s
and not exists (
select 1
from aihr_knowledge_source_governance invalid_governance
@@ -109,7 +92,8 @@ public class AihrKnowledgeCitationDetailService {
)
)
order by a.id desc limit 1
""".formatted(String.join(",", java.util.Collections.nCopies(spaces.size(), "?"))),
""".formatted(String.join(",", java.util.Collections.nCopies(spaces.size(), "?")),
AihrKnowledgeRolloutService.servingFragmentExistsSql("f")),
(rs, n) -> new Row(rs.getLong("fragment_id"), rs.getString("content"), rs.getString("doc_id"),
rs.getLong("knowledge_id"), rs.getString("title"), rs.getObject("attachment_id", Long.class),
rs.getString("attachment_type"), rs.getObject("oss_id", Long.class), rs.getString("source_kind"),
@@ -134,27 +118,10 @@ public class AihrKnowledgeCitationDetailService {
List<TextSegment> rows = jdbcTemplate.query("""
select f.id, f.idx, f.content from aihr_knowledge_fragment f
where f.tenant_id = ? and f.knowledge_id = ? and f.doc_id = ?
and exists (
select 1
from aihr_chunk_revision governed_chunk
join aihr_data_asset governed_asset
on governed_asset.tenant_id = governed_chunk.tenant_id
and governed_asset.id = governed_chunk.asset_id
and governed_asset.current_version_id = governed_chunk.version_id
join aihr_dataset_membership governed_dataset
on governed_dataset.tenant_id = governed_chunk.tenant_id
and governed_dataset.version_id = governed_chunk.version_id
and governed_dataset.dataset_code = 'production'
and governed_dataset.status = 'ACTIVE'
where governed_chunk.tenant_id = f.tenant_id
and governed_chunk.published_fragment_id = f.id
and governed_asset.lifecycle_status = 'PUBLISHED'
and governed_asset.trust_level = 'HUMAN_VERIFIED'
and (governed_asset.effective_from is null or governed_asset.effective_from <= current_date())
and (governed_asset.effective_to is null or governed_asset.effective_to >= current_date())
)
and %s
order by f.idx, f.id limit 200
""", (rs, n) -> new TextSegment(rs.getInt("idx"), truncate(rs.getString("content"), 4_000),
""".formatted(AihrKnowledgeRolloutService.servingFragmentExistsSql("f")),
(rs, n) -> new TextSegment(rs.getInt("idx"), truncate(rs.getString("content"), 4_000),
rs.getLong("id") == targetId), tenantId, knowledgeId, docId);
int chars = 0;
List<TextSegment> bounded = new ArrayList<>();
@@ -86,7 +86,8 @@ public class AihrKnowledgeQueryAuditService {
from aihr_chunk_revision c
join aihr_data_asset a on a.tenant_id = c.tenant_id and a.id = c.asset_id
where c.tenant_id = ? and c.published_fragment_id = ?
and a.lifecycle_status = 'PUBLISHED' and a.current_version_id = c.version_id
and a.lifecycle_status = 'PUBLISHED'
and coalesce(a.published_version_id, a.current_version_id) = c.version_id
limit 1
""", tenantId, requestId, rank, fragmentId,
normalizedChannel(snippet.retrievalChannel()), snippet.retrievalScore(),
@@ -11,6 +11,7 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Citation;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.BroadcastContext;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse;
import org.dromara.aihr.knowledge.quality.AihrKnowledgeRolloutService;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Resource;
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.ConversationContext;
import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.ToolResult;
@@ -317,25 +318,10 @@ public class AihrKnowledgeQueryService {
join aihr_knowledge_info k on k.id = a.knowledge_id and k.tenant_id = a.tenant_id
where a.tenant_id = ? and a.id = ? and a.oss_id is not null and a.status = 2
and a.knowledge_id in (%s) and k.status = 'ACTIVE'
and exists (
select 1
from aihr_data_asset governed_asset
join aihr_dataset_membership governed_dataset
on governed_dataset.tenant_id = governed_asset.tenant_id
and governed_dataset.version_id = governed_asset.current_version_id
and governed_dataset.dataset_code = 'production'
and governed_dataset.status = 'ACTIVE'
where governed_asset.tenant_id = a.tenant_id
and governed_asset.attachment_id = a.id
and governed_asset.knowledge_id = a.knowledge_id
and governed_asset.doc_id = a.doc_id
and governed_asset.lifecycle_status = 'PUBLISHED'
and governed_asset.trust_level = 'HUMAN_VERIFIED'
and (governed_asset.effective_from is null or governed_asset.effective_from <= current_date())
and (governed_asset.effective_to is null or governed_asset.effective_to >= current_date())
)
and %s
limit 1
""".formatted(placeholders(spaceIds.size())), Long.class, args.toArray());
""".formatted(placeholders(spaceIds.size()),
AihrKnowledgeRolloutService.servingAttachmentExistsSql("a")), Long.class, args.toArray());
}
if (!rows.isEmpty()) {
return rows.get(0);
@@ -762,7 +748,7 @@ public class AihrKnowledgeQueryService {
join aihr_data_asset governed_title_asset
on governed_title_asset.tenant_id = governed_title_chunk.tenant_id
and governed_title_asset.id = governed_title_chunk.asset_id
and governed_title_asset.current_version_id = governed_title_chunk.version_id
and coalesce(governed_title_asset.published_version_id, governed_title_asset.current_version_id) = governed_title_chunk.version_id
join aihr_data_version governed_version
on governed_version.tenant_id = governed_title_chunk.tenant_id
and governed_version.id = governed_title_chunk.version_id
@@ -784,7 +770,7 @@ public class AihrKnowledgeQueryService {
join aihr_data_asset governed_asset
on governed_asset.tenant_id = governed_chunk.tenant_id
and governed_asset.id = governed_chunk.asset_id
and governed_asset.current_version_id = governed_chunk.version_id
and coalesce(governed_asset.published_version_id, governed_asset.current_version_id) = governed_chunk.version_id
join aihr_dataset_membership governed_dataset
on governed_dataset.tenant_id = governed_chunk.tenant_id
and governed_dataset.version_id = governed_chunk.version_id
@@ -706,7 +706,8 @@ public class AihrExamService {
AND EXISTS (
SELECT 1 FROM aihr_chunk_revision c
JOIN aihr_data_asset a ON a.tenant_id = c.tenant_id AND a.id = c.asset_id
AND a.current_version_id = c.version_id AND a.lifecycle_status = 'PUBLISHED'
AND coalesce(a.published_version_id, a.current_version_id) = c.version_id
AND a.lifecycle_status = 'PUBLISHED'
AND a.trust_level = 'HUMAN_VERIFIED'
JOIN aihr_dataset_membership d ON d.tenant_id = c.tenant_id AND d.version_id = c.version_id
AND d.dataset_code = 'production' AND d.status = 'ACTIVE'
@@ -729,7 +730,8 @@ public class AihrExamService {
AND EXISTS (
SELECT 1 FROM aihr_chunk_revision c
JOIN aihr_data_asset a ON a.tenant_id = c.tenant_id AND a.id = c.asset_id
AND a.current_version_id = c.version_id AND a.lifecycle_status = 'PUBLISHED'
AND coalesce(a.published_version_id, a.current_version_id) = c.version_id
AND a.lifecycle_status = 'PUBLISHED'
AND a.trust_level = 'HUMAN_VERIFIED'
JOIN aihr_dataset_membership d ON d.tenant_id = c.tenant_id AND d.version_id = c.version_id
AND d.dataset_code = 'production' AND d.status = 'ACTIVE'
@@ -48,6 +48,7 @@ import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier;
import org.dromara.aihr.knowledge.quality.AihrKnowledgeLifecycle.UsageType;
import org.dromara.aihr.knowledge.quality.AihrKnowledgeLifecycle.ReasonCode;
import org.dromara.aihr.knowledge.quality.AihrKnowledgeLifecycleService;
import org.dromara.aihr.knowledge.quality.AihrKnowledgeRolloutService;
import org.dromara.aihr.knowledge.quality.AihrKnowledgePrivacyService;
import org.dromara.aihr.knowledge.quality.AihrKnowledgeTextProcessor;
import org.dromara.aihr.personal.service.EnterpriseKnowledgeAccessPolicy.EnterpriseKnowledgeGrant;
@@ -286,7 +287,7 @@ public class AihrSopSeedService {
and match(f.content) against (? in natural language mode)
order by score desc, f.id asc
limit ?
""".formatted(placeholders, publishedLifecycleScopeSql("f"));
""".formatted(placeholders, servingLifecycleScopeSql("f"));
List<Object> args = new ArrayList<>();
args.add(query);
args.add(grant.tenantId());
@@ -706,7 +707,7 @@ public class AihrSopSeedService {
and f.id in (%s)
%s
%s
""".formatted(placeholders, knowledgeScopeSql("f", allowedKnowledgeIds), publishedLifecycleScopeSql("f"));
""".formatted(placeholders, knowledgeScopeSql("f", allowedKnowledgeIds), servingLifecycleScopeSql("f"));
List<Object> args = new ArrayList<>();
args.add(tenantId());
args.addAll(fragmentIds);
@@ -872,6 +873,17 @@ public class AihrSopSeedService {
*/
@Transactional(rollbackFor = Exception.class)
public UploadResponse reprocessStagedAttachment(Long attachmentId, Path stagedFile) {
return reprocessStagedAttachment(attachmentId, stagedFile, true);
}
/** Stages a governed candidate from a ready legacy attachment without changing legacy service state. */
@Transactional(rollbackFor = Exception.class)
public UploadResponse stageLegacyAttachmentCandidate(Long attachmentId, Path stagedFile) {
return reprocessStagedAttachment(attachmentId, stagedFile, false);
}
private UploadResponse reprocessStagedAttachment(Long attachmentId, Path stagedFile,
boolean updateLegacyAttachment) {
if (attachmentId == null || attachmentId <= 0 || stagedFile == null || !Files.isRegularFile(stagedFile)) {
throw new ServiceException("MEDIA_RETRY_SOURCE_MISSING: 待重试资料不存在");
}
@@ -897,7 +909,7 @@ public class AihrSopSeedService {
String docId = isBlank(attachment.docId())
? UUID.randomUUID().toString().replace("-", "") : attachment.docId();
if (!docId.equals(attachment.docId())) {
if (updateLegacyAttachment && !docId.equals(attachment.docId())) {
jdbcTemplate.update("""
update aihr_knowledge_attach set doc_id = ?, update_time = now()
where tenant_id = ? and id = ?
@@ -912,8 +924,10 @@ public class AihrSopSeedService {
AihrKnowledgeLifecycleService.StagedAsset staged = stageRawFailureCandidate(
attachment.knowledgeId(), docId, attachment.ossId(), attachment.fileName(), sourceFingerprint,
reasonCode, safeExtractionEvidence(error), AihrExtractionQuality.none());
markAttachStatus(attachment.knowledgeId(), docId, 3,
"quality-quarantined:asset=" + staged.assetId() + ":reason=" + reasonCode.name());
if (updateLegacyAttachment) {
markAttachStatus(attachment.knowledgeId(), docId, 3,
"quality-quarantined:asset=" + staged.assetId() + ":reason=" + reasonCode.name());
}
return quarantinedUploadResponse(docId, attachment.ossId(), attachment.fileName(),
attachment.spaceName(), reasonCode);
}
@@ -924,8 +938,10 @@ public class AihrSopSeedService {
AihrKnowledgeLifecycleService.StagedAsset staged = stageRawFailureCandidate(
attachment.knowledgeId(), docId, attachment.ossId(), attachment.fileName(), sourceFingerprint,
reasonCode, "No usable text was extracted during reprocessing", extracted.quality());
markAttachStatus(attachment.knowledgeId(), docId, 3,
"quality-quarantined:asset=" + staged.assetId() + ":reason=" + reasonCode.name());
if (updateLegacyAttachment) {
markAttachStatus(attachment.knowledgeId(), docId, 3,
"quality-quarantined:asset=" + staged.assetId() + ":reason=" + reasonCode.name());
}
return quarantinedUploadResponse(docId, attachment.ossId(), attachment.fileName(),
attachment.spaceName(), reasonCode);
}
@@ -939,9 +955,11 @@ public class AihrSopSeedService {
AihrKnowledgeLifecycleService.StagedAsset staged = stageCandidate(
attachment.knowledgeId(), docId, attachment.ossId(), attachment.fileName(), content, fragments,
fingerprint, extracted.quality(), chunkPlan.locators());
updateOssInsight(attachment.ossId(), insight, fingerprint);
markAttachStatus(attachment.knowledgeId(), docId, 2,
"review-pending:asset=" + staged.assetId());
if (updateLegacyAttachment) {
updateOssInsight(attachment.ossId(), insight, fingerprint);
markAttachStatus(attachment.knowledgeId(), docId, 2,
"review-pending:asset=" + staged.assetId());
}
List<SnippetResponse> snippets = fragments.stream()
.limit(3)
@@ -1133,7 +1151,7 @@ public class AihrSopSeedService {
return new VectorIndexStatusResponse(
runtime.modelName(),
expectedDimension,
qdrantCollection(),
governedQdrantCollection(),
qdrant.dimension(),
qdrant.points(),
db.fragments(),
@@ -2220,7 +2238,8 @@ public class AihrSopSeedService {
and a.index_status = 'READY' then a.id end) indexed_assets
from aihr_data_asset a
left join aihr_chunk_revision c on c.tenant_id = a.tenant_id
and c.asset_id = a.id and c.version_id = a.current_version_id
and c.asset_id = a.id
and c.version_id = coalesce(a.published_version_id, a.current_version_id)
left join aihr_knowledge_fragment f on f.tenant_id = c.tenant_id
and f.id = c.published_fragment_id
where a.tenant_id = ?
@@ -2632,16 +2651,26 @@ public class AihrSopSeedService {
}
public int vectorizePublishedDocument(long knowledgeId, String docId) {
AihrKnowledgeRolloutService.ModeGeneration rollout = rolloutModeGeneration(knowledgeId);
return vectorizePublishedDocument(knowledgeId, docId, null,
firstNonBlank(rollout.governedCollection(), governedQdrantCollection()), rollout.activeGeneration());
}
public int vectorizePublishedDocument(long knowledgeId, String docId, Long expectedVersionId,
String targetCollection, long targetGeneration) {
List<DocumentVectorRow> rows = jdbcTemplate.query("""
select i.name, f.content
from aihr_knowledge_fragment f
join aihr_knowledge_info i on i.tenant_id = f.tenant_id and i.id = f.knowledge_id
left join aihr_data_asset a on a.tenant_id = f.tenant_id
and a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id
where f.tenant_id = ? and f.knowledge_id = ? and f.doc_id = ?
%s
and (? is null or a.published_version_id = ?)
order by f.idx
""".formatted(publishedLifecycleScopeSql("f")),
(rs, rowNum) -> new DocumentVectorRow(rs.getString(1), rs.getString(2)),
tenantId(), knowledgeId, docId);
tenantId(), knowledgeId, docId, expectedVersionId, expectedVersionId);
if (rows.isEmpty()) {
throw new ServiceException("Published document has no authorized fragments", 409);
}
@@ -2660,7 +2689,8 @@ public class AihrSopSeedService {
data.embeddings().get(index), data.modelName(), tenantId(), knowledgeId, docId, index + 1);
}
try {
upsertQdrant(knowledgeId, rows.get(0).category(), docId, fragments, data.embeddings(), data.modelName());
upsertQdrant(knowledgeId, rows.get(0).category(), docId, fragments, data.embeddings(), data.modelName(),
expectedVersionId, targetCollection, targetGeneration);
} catch (Exception ex) {
throw new ServiceException("Vector index upsert failed").setDetailMessage(ex.getMessage());
}
@@ -2668,11 +2698,18 @@ public class AihrSopSeedService {
}
public void deleteVectorDocument(long knowledgeId, String docId) {
deleteVectorDocument(knowledgeId, docId, governedQdrantCollection(), null);
}
public void deleteVectorDocument(long knowledgeId, String docId, String targetCollection, Long generation) {
try {
ObjectNode body = objectMapper.createObjectNode();
body.set("filter", qdrantFilter(knowledgeId, docId, null));
body.set("filter", generation == null
? qdrantFilter(knowledgeId, docId, null)
: qdrantProductionFilterForSpaces(Set.of(knowledgeId), docId, null, generation));
HttpResponse<String> response = qdrantRequest(
"POST", "/collections/" + qdrantCollection() + "/points/delete?wait=true", body);
"POST", "/collections/" + firstNonBlank(targetCollection, governedQdrantCollection())
+ "/points/delete?wait=true", body);
if (response.statusCode() != 404 && !ok(response.statusCode())) {
throw new IllegalStateException("qdrant delete HTTP " + response.statusCode());
}
@@ -3520,6 +3557,10 @@ public class AihrSopSeedService {
return "and " + publishedLifecycleExistsSql(fragmentAlias);
}
private static String servingLifecycleScopeSql(String fragmentAlias) {
return "and " + AihrKnowledgeRolloutService.servingFragmentExistsSql(fragmentAlias);
}
private static String publishedLifecycleExistsSql(String fragmentAlias) {
return """
exists (
@@ -3528,7 +3569,7 @@ public class AihrSopSeedService {
join aihr_data_asset governed_asset
on governed_asset.tenant_id = governed_chunk.tenant_id
and governed_asset.id = governed_chunk.asset_id
and governed_asset.current_version_id = governed_chunk.version_id
and coalesce(governed_asset.published_version_id, governed_asset.current_version_id) = governed_chunk.version_id
join aihr_dataset_membership governed_dataset
on governed_dataset.tenant_id = governed_chunk.tenant_id
and governed_dataset.version_id = governed_chunk.version_id
@@ -3644,7 +3685,7 @@ public class AihrSopSeedService {
order by score desc, f.idx asc
limit ?
""".formatted(knowledgeScopeSql("f", allowedKnowledgeIds),
formalSourceScopeSql("f", formalPolicyOnly), publishedLifecycleScopeSql("f"));
formalSourceScopeSql("f", formalPolicyOnly), servingLifecycleScopeSql("f"));
List<Object> args = new ArrayList<>();
args.add(queryText);
args.add(tenantId());
@@ -3707,7 +3748,7 @@ public class AihrSopSeedService {
order by score desc, f.idx asc
limit ?
""".formatted(String.join(" + ", scoreParts), knowledgeScopeSql("f", allowedKnowledgeIds),
formalSourceScopeSql("f", formalPolicyOnly), publishedLifecycleScopeSql("f"),
formalSourceScopeSql("f", formalPolicyOnly), servingLifecycleScopeSql("f"),
String.join(" or ", whereParts));
List<Object> args = new ArrayList<>();
terms.stream().map(AihrSopSeedService::likePattern).forEach(args::add);
@@ -3755,14 +3796,16 @@ public class AihrSopSeedService {
%s
%s
%s
and f.knowledge_id = ?
and f.doc_id = ?
and f.idx = ?
limit 1
""".formatted(knowledgeScopeSql("f", allowedKnowledgeIds),
formalSourceScopeSql("f", formalPolicyOnly), publishedLifecycleScopeSql("f"));
formalSourceScopeSql("f", formalPolicyOnly), servingLifecycleScopeSql("f"));
List<Object> args = new ArrayList<>();
args.add(tenantId());
addKnowledgeScopeArgs(args, allowedKnowledgeIds);
args.add(match.knowledgeId());
args.add(match.docId());
args.add(match.idx());
List<KnowledgeHit> rows = jdbcTemplate.query(sql, (rs, rowNum) -> new KnowledgeHit(
@@ -4286,6 +4329,14 @@ public class AihrSopSeedService {
}
private void upsertQdrant(long knowledgeId, String category, String docId, List<String> fragments, List<String> embeddings, String modelName) throws Exception {
AihrKnowledgeRolloutService.ModeGeneration rollout = rolloutModeGeneration(knowledgeId);
upsertQdrant(knowledgeId, category, docId, fragments, embeddings, modelName, null,
firstNonBlank(rollout.governedCollection(), governedQdrantCollection()), rollout.activeGeneration());
}
private void upsertQdrant(long knowledgeId, String category, String docId, List<String> fragments,
List<String> embeddings, String modelName, Long expectedVersionId,
String targetCollection, long targetGeneration) throws Exception {
if (fragments.isEmpty() || embeddings.isEmpty()) {
return;
}
@@ -4293,9 +4344,41 @@ public class AihrSopSeedService {
if (!firstVector.isArray() || firstVector.size() == 0) {
return;
}
ensureQdrantCollection(firstVector.size());
String governedCollection = firstNonBlank(targetCollection, governedQdrantCollection());
ensureQdrantCollection(governedCollection, firstVector.size());
long generation = Math.max(1L, targetGeneration);
Map<Integer, GovernedVectorLineage> governedLineage = jdbcTemplate.query("""
select a.id asset_id, c.version_id, c.id chunk_revision_id, c.chunk_index,
c.content_sha256, c.locator_json, c.published_fragment_id
from aihr_data_asset a
join aihr_chunk_revision c on c.tenant_id = a.tenant_id and c.asset_id = a.id
and c.version_id = coalesce(a.published_version_id, a.current_version_id)
where a.tenant_id = ? and a.knowledge_id = ? and a.doc_id = ?
and a.lifecycle_status = 'PUBLISHED' and a.trust_level = 'HUMAN_VERIFIED'
and (? is null or c.version_id = ?)
order by c.chunk_index
""", rs -> {
Map<Integer, GovernedVectorLineage> result = new LinkedHashMap<>();
while (rs.next()) {
result.put(rs.getInt("chunk_index"), new GovernedVectorLineage(
rs.getLong("asset_id"), rs.getLong("version_id"), rs.getLong("chunk_revision_id"),
rs.getString("content_sha256"), rs.getString("locator_json"),
rs.getObject("published_fragment_id", Long.class)));
}
return result;
}, tenantId(), knowledgeId, docId, expectedVersionId, expectedVersionId);
ObjectNode deleteBody = objectMapper.createObjectNode();
deleteBody.set("filter", qdrantProductionFilterForSpaces(Set.of(knowledgeId), docId, null, generation));
HttpResponse<String> deleteResponse = qdrantRequest(
"POST", "/collections/" + governedCollection + "/points/delete?wait=true", deleteBody);
if (deleteResponse.statusCode() != 404 && !ok(deleteResponse.statusCode())) {
throw new IllegalStateException("qdrant generation cleanup HTTP " + deleteResponse.statusCode());
}
ArrayNode points = objectMapper.createArrayNode();
Map<Long, String> pointIds = new LinkedHashMap<>();
int size = Math.min(fragments.size(), embeddings.size());
for (int i = 0; i < size; i++) {
JsonNode vector = objectMapper.readTree(embeddings.get(i));
@@ -4312,9 +4395,29 @@ public class AihrSopSeedService {
payload.put("dataset_code", "production");
payload.put("lifecycle_status", "PUBLISHED");
payload.put("trust_level", "HUMAN_VERIFIED");
payload.put("index_generation", generation);
payload.put("content_sha256", AihrKnowledgeLifecycleService.sha256(fragments.get(i)));
GovernedVectorLineage lineage = governedLineage.get(i + 1);
String pointId = qdrantPointId(knowledgeId, docId, i + 1);
if (lineage != null) {
payload.put("asset_id", lineage.assetId());
payload.put("version_id", lineage.versionId());
payload.put("chunk_revision_id", lineage.chunkRevisionId());
if (lineage.publishedFragmentId() != null) {
payload.put("fragment_id", lineage.publishedFragmentId());
}
payload.put("content_sha256", lineage.contentSha256());
if (!isBlank(lineage.locatorJson())) {
payload.set("locator", objectMapper.readTree(lineage.locatorJson()));
}
pointId = governedQdrantPointId(lineage.assetId(), lineage.versionId(),
lineage.chunkRevisionId(), generation);
pointIds.put(lineage.chunkRevisionId(), pointId);
}
ObjectNode point = objectMapper.createObjectNode();
point.put("id", qdrantPointId(knowledgeId, docId, i + 1));
point.put("id", pointId);
point.set("vector", vector);
point.set("payload", payload);
points.add(point);
@@ -4325,10 +4428,14 @@ public class AihrSopSeedService {
ObjectNode body = objectMapper.createObjectNode();
body.set("points", points);
HttpResponse<String> response = qdrantRequest("PUT", "/collections/" + qdrantCollection() + "/points?wait=true", body);
HttpResponse<String> response = qdrantRequest("PUT", "/collections/" + governedCollection + "/points?wait=true", body);
if (!ok(response.statusCode())) {
throw new IllegalStateException("qdrant upsert HTTP " + response.statusCode());
}
pointIds.forEach((chunkRevisionId, pointId) -> jdbcTemplate.update("""
update aihr_chunk_revision set index_generation = ?, vector_point_id = ?
where tenant_id = ? and id = ?
""", generation, pointId, tenantId(), chunkRevisionId));
}
private void deleteQdrantDoc(long knowledgeId, String docId) {
@@ -4339,7 +4446,7 @@ public class AihrSopSeedService {
try {
ObjectNode body = objectMapper.createObjectNode();
body.set("filter", qdrantFilter(knowledgeId, docId, null));
HttpResponse<String> response = qdrantRequest("POST", "/collections/" + qdrantCollection() + "/points/delete?wait=true", body);
HttpResponse<String> response = qdrantRequest("POST", "/collections/" + governedQdrantCollection() + "/points/delete?wait=true", body);
if (response.statusCode() == 404) {
return;
}
@@ -4370,14 +4477,46 @@ public class AihrSopSeedService {
}
private List<VectorMatch> queryQdrant(String category, String embeddingJson, int limit, Set<Long> allowedKnowledgeIds) throws Exception {
List<RolloutVectorScope> scopes = rolloutVectorScopes(allowedKnowledgeIds);
List<VectorMatch> matches = new ArrayList<>();
Set<Long> legacySpaces = new LinkedHashSet<>();
Map<String, Set<Long>> governedSpaces = new LinkedHashMap<>();
for (RolloutVectorScope scope : scopes) {
if ("ENFORCED".equals(scope.mode())) {
String key = scope.collection() + "\n" + scope.generation();
governedSpaces.computeIfAbsent(key, ignored -> new LinkedHashSet<>()).add(scope.knowledgeId());
} else {
legacySpaces.add(scope.knowledgeId());
}
}
if (!legacySpaces.isEmpty()) {
matches.addAll(queryQdrantCollection(legacyQdrantCollection(), category, embeddingJson,
limit, legacySpaces, null));
}
for (Map.Entry<String, Set<Long>> entry : governedSpaces.entrySet()) {
int split = entry.getKey().lastIndexOf('\n');
String collection = entry.getKey().substring(0, split);
long generation = Long.parseLong(entry.getKey().substring(split + 1));
matches.addAll(queryQdrantCollection(collection, category, embeddingJson,
limit, entry.getValue(), generation));
}
return matches.stream().sorted(Comparator.comparingDouble(VectorMatch::score).reversed())
.limit(limit).toList();
}
private List<VectorMatch> queryQdrantCollection(String collection, String category, String embeddingJson,
int limit, Set<Long> knowledgeIds,
Long generation) throws Exception {
ObjectNode body = objectMapper.createObjectNode();
body.set("query", objectMapper.readTree(embeddingJson));
body.set("filter", qdrantProductionFilterForSpaces(allowedKnowledgeIds, null, category));
body.set("filter", generation == null
? qdrantFilterForSpaces(knowledgeIds, null, category)
: qdrantProductionFilterForSpaces(knowledgeIds, null, category, generation));
body.put("limit", limit);
body.put("with_payload", true);
body.put("with_vector", false);
HttpResponse<String> response = qdrantRequest("POST", "/collections/" + qdrantCollection() + "/points/query", body);
HttpResponse<String> response = qdrantRequest("POST", "/collections/" + collection + "/points/query", body);
if (!ok(response.statusCode())) {
return List.of();
}
@@ -4388,15 +4527,78 @@ public class AihrSopSeedService {
List<VectorMatch> matches = new ArrayList<>();
for (JsonNode point : points) {
JsonNode payload = point.path("payload");
long knowledgeId = payload.path("knowledge_id").asLong(0L);
String docId = payload.path("doc_id").asText("");
int idx = payload.path("idx").asInt(0);
if (!docId.isBlank() && idx > 0) {
matches.add(new VectorMatch(docId, idx, point.path("score").asDouble()));
if (knowledgeId > 0 && !docId.isBlank() && idx > 0) {
matches.add(new VectorMatch(knowledgeId, docId, idx, point.path("score").asDouble()));
}
}
return matches;
}
private List<RolloutVectorScope> rolloutVectorScopes(Set<Long> allowedKnowledgeIds) {
String idScope = allowedKnowledgeIds == null || allowedKnowledgeIds.isEmpty()
? "" : "and k.id in (" + String.join(",",
java.util.Collections.nCopies(allowedKnowledgeIds.size(), "?")) + ")";
List<Object> args = new ArrayList<>();
args.add(AihrKnowledgeRolloutService.DEFAULT_GOVERNED_COLLECTION);
args.add(tenantId());
if (allowedKnowledgeIds != null && !allowedKnowledgeIds.isEmpty()) {
args.addAll(allowedKnowledgeIds);
}
try {
return jdbcTemplate.query("""
select k.id knowledge_id, coalesce(s.mode, 'LEGACY') mode,
coalesce(s.governed_collection, ?) governed_collection,
coalesce(s.active_generation, 1) active_generation
from aihr_knowledge_info k
left join aihr_knowledge_rollout_scope s
on s.tenant_id = k.tenant_id and s.knowledge_id = k.id
where k.tenant_id = ? and k.status = 'ACTIVE' %s
order by k.id
""".formatted(idScope), (rs, rowNum) -> new RolloutVectorScope(
rs.getLong("knowledge_id"), rs.getString("mode"),
rs.getString("governed_collection"), rs.getLong("active_generation")), args.toArray());
} catch (DataAccessException ignored) {
return legacyVectorScopes(allowedKnowledgeIds);
}
}
private List<RolloutVectorScope> legacyVectorScopes(Set<Long> allowedKnowledgeIds) {
if (allowedKnowledgeIds != null && !allowedKnowledgeIds.isEmpty()) {
return allowedKnowledgeIds.stream().map(id -> new RolloutVectorScope(id, "LEGACY",
AihrKnowledgeRolloutService.DEFAULT_GOVERNED_COLLECTION, 1L)).toList();
}
try {
return jdbcTemplate.queryForList("""
select id from aihr_knowledge_info where tenant_id = ? and status = 'ACTIVE'
""", Long.class, tenantId()).stream().map(id -> new RolloutVectorScope(id, "LEGACY",
AihrKnowledgeRolloutService.DEFAULT_GOVERNED_COLLECTION, 1L)).toList();
} catch (DataAccessException ignored) {
return List.of();
}
}
private AihrKnowledgeRolloutService.ModeGeneration rolloutModeGeneration(long knowledgeId) {
try {
List<AihrKnowledgeRolloutService.ModeGeneration> rows = jdbcTemplate.query("""
select mode, governed_collection, active_generation
from aihr_knowledge_rollout_scope
where tenant_id = ? and knowledge_id = ? limit 1
""", (rs, rowNum) -> new AihrKnowledgeRolloutService.ModeGeneration(
rs.getString("mode"), rs.getString("governed_collection"),
rs.getLong("active_generation")), tenantId(), knowledgeId);
if (!rows.isEmpty()) {
return rows.get(0);
}
} catch (DataAccessException ignored) {
// Schema can be installed before or together with the compatibility JAR.
}
return new AihrKnowledgeRolloutService.ModeGeneration("LEGACY",
AihrKnowledgeRolloutService.DEFAULT_GOVERNED_COLLECTION, 1L);
}
private VectorDbStats vectorDbStats() {
try {
return jdbcTemplate.queryForObject("""
@@ -4438,7 +4640,7 @@ public class AihrSopSeedService {
private QdrantStats qdrantStats() {
try {
HttpResponse<String> response = qdrantRequest("GET", "/collections/" + qdrantCollection(), null);
HttpResponse<String> response = qdrantRequest("GET", "/collections/" + governedQdrantCollection(), null);
if (response.statusCode() == 404) {
return new QdrantStats(null, 0L, "Qdrant collection 不存在");
}
@@ -4456,8 +4658,8 @@ public class AihrSopSeedService {
}
}
private void ensureQdrantCollection(int vectorSize) throws Exception {
HttpResponse<String> current = qdrantRequest("GET", "/collections/" + qdrantCollection(), null);
private void ensureQdrantCollection(String collection, int vectorSize) throws Exception {
HttpResponse<String> current = qdrantRequest("GET", "/collections/" + collection, null);
if (ok(current.statusCode())) {
int currentSize = objectMapper.readTree(current.body())
.path("result")
@@ -4480,7 +4682,7 @@ public class AihrSopSeedService {
vectors.put("distance", "Cosine");
ObjectNode body = objectMapper.createObjectNode();
body.set("vectors", vectors);
HttpResponse<String> created = qdrantRequest("PUT", "/collections/" + qdrantCollection(), body);
HttpResponse<String> created = qdrantRequest("PUT", "/collections/" + collection, body);
if (!ok(created.statusCode())) {
throw new IllegalStateException("qdrant create collection HTTP " + created.statusCode());
}
@@ -4490,7 +4692,7 @@ public class AihrSopSeedService {
try {
ObjectNode body = objectMapper.createObjectNode();
body.set("filter", qdrantProductionFilterForSpaces(null, null, null));
HttpResponse<String> response = qdrantRequest("POST", "/collections/" + qdrantCollection() + "/points/delete?wait=true", body);
HttpResponse<String> response = qdrantRequest("POST", "/collections/" + governedQdrantCollection() + "/points/delete?wait=true", body);
if (response.statusCode() == 404) {
return;
}
@@ -4507,7 +4709,7 @@ public class AihrSopSeedService {
ObjectNode body = objectMapper.createObjectNode();
body.set("filter", qdrantProductionFilterForSpaces(null, null, null));
body.put("exact", true);
HttpResponse<String> response = qdrantRequest("POST", "/collections/" + qdrantCollection() + "/points/count", body);
HttpResponse<String> response = qdrantRequest("POST", "/collections/" + governedQdrantCollection() + "/points/count", body);
if (!ok(response.statusCode())) {
return null;
}
@@ -4539,11 +4741,19 @@ public class AihrSopSeedService {
}
private ObjectNode qdrantProductionFilterForSpaces(Set<Long> knowledgeIds, String docId, String category) {
return qdrantProductionFilterForSpaces(knowledgeIds, docId, category, null);
}
private ObjectNode qdrantProductionFilterForSpaces(Set<Long> knowledgeIds, String docId, String category,
Long generation) {
ObjectNode filter = qdrantFilterForSpaces(knowledgeIds, docId, category);
ArrayNode must = (ArrayNode) filter.path("must");
must.add(qdrantMatch("dataset_code", "production"));
must.add(qdrantMatch("lifecycle_status", "PUBLISHED"));
must.add(qdrantMatch("trust_level", "HUMAN_VERIFIED"));
if (generation != null) {
must.add(qdrantMatch("index_generation", generation));
}
return filter;
}
@@ -4968,8 +5178,15 @@ public class AihrSopSeedService {
return normalizeBaseUrl(firstNonBlank(System.getProperty("aihr.qdrant.url"), System.getenv("AIHR_QDRANT_URL"), "http://127.0.0.1:6333"));
}
private static String qdrantCollection() {
return firstNonBlank(System.getProperty("aihr.qdrant.collection"), System.getenv("AIHR_QDRANT_COLLECTION"), "aihr_knowledge");
private static String legacyQdrantCollection() {
return firstNonBlank(System.getProperty("aihr.qdrant.collection"),
System.getenv("AIHR_QDRANT_COLLECTION"), "aihr_knowledge");
}
private static String governedQdrantCollection() {
return firstNonBlank(System.getProperty("aihr.qdrant.governed-collection"),
System.getenv("AIHR_QDRANT_GOVERNED_COLLECTION"),
AihrKnowledgeRolloutService.DEFAULT_GOVERNED_COLLECTION);
}
private static String qdrantApiKey() {
@@ -4980,6 +5197,11 @@ public class AihrSopSeedService {
return UUID.nameUUIDFromBytes((tenantId() + ":" + knowledgeId + ":" + docId + ":" + idx).getBytes(StandardCharsets.UTF_8)).toString();
}
private String governedQdrantPointId(long assetId, long versionId, long chunkRevisionId, long generation) {
return UUID.nameUUIDFromBytes((tenantId() + ":governed:" + assetId + ":" + versionId + ":"
+ chunkRevisionId + ":" + generation).getBytes(StandardCharsets.UTF_8)).toString();
}
private static boolean ok(int statusCode) {
return statusCode >= 200 && statusCode < 300;
}
@@ -5185,6 +5407,10 @@ public class AihrSopSeedService {
private record DocumentVectorRow(String category, String content) {
}
private record GovernedVectorLineage(long assetId, long versionId, long chunkRevisionId,
String contentSha256, String locatorJson, Long publishedFragmentId) {
}
private record DuplicateHit(long attachId, long knowledgeId, String docId, long ossId, String matchType) {
}
@@ -5198,7 +5424,10 @@ public class AihrSopSeedService {
private record VideoMarker(boolean frame, long startMs, Long endMs) {
}
private record VectorMatch(String docId, int idx, double score) {
private record VectorMatch(long knowledgeId, String docId, int idx, double score) {
}
private record RolloutVectorScope(long knowledgeId, String mode, String collection, long generation) {
}
private record LocalImportPlan(Path root, String directory, String category, List<Path> files) {
@@ -24,7 +24,7 @@ import static org.mockito.Mockito.when;
@Tag("dev")
class AihrKnowledgeCitationDetailServiceTest {
@Test
void detailAndAdjacentSegmentsRequireCurrentPublishedLifecycle() throws Exception {
void detailAndAdjacentSegmentsUseTheSharedRolloutLifecyclePredicate() throws Exception {
Path source = Path.of(
"src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeCitationDetailService.java");
if (!Files.exists(source)) {
@@ -33,10 +33,7 @@ class AihrKnowledgeCitationDetailServiceTest {
}
String code = Files.readString(source);
assertTrue(code.split("governed_chunk.published_fragment_id = f.id", -1).length - 1 >= 2);
assertTrue(code.contains("governed_asset.current_version_id = governed_chunk.version_id"));
assertTrue(code.contains("governed_asset.lifecycle_status = 'PUBLISHED'"));
assertTrue(code.contains("governed_dataset.dataset_code = 'production'"));
assertTrue(code.split("servingFragmentExistsSql", -1).length - 1 >= 2);
}
@Test
@@ -55,7 +55,7 @@ import static org.mockito.ArgumentMatchers.eq;
class AihrKnowledgeQueryServiceTest {
@Test
void citationHydrationAndResourceDownloadsRequirePublishedLifecycle() throws Exception {
void resourceDownloadsUseTheSharedRolloutLifecyclePredicate() throws Exception {
Path source = Path.of(
"src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java");
if (!Files.exists(source)) {
@@ -64,12 +64,7 @@ class AihrKnowledgeQueryServiceTest {
}
String code = Files.readString(source);
assertTrue(code.contains("governed_chunk.published_fragment_id = f.id"));
assertTrue(code.contains("governed_asset.attachment_id = a.id"));
assertTrue(code.contains("governed_asset.lifecycle_status = 'PUBLISHED'"));
assertTrue(code.contains("governed_asset.trust_level = 'HUMAN_VERIFIED'"));
assertTrue(code.contains("governed_dataset.dataset_code = 'production'"));
assertTrue(code.contains("governed_dataset.status = 'ACTIVE'"));
assertTrue(code.contains("AihrKnowledgeRolloutService.servingAttachmentExistsSql(\"a\")"));
}
@Test
@@ -19,6 +19,7 @@ import static org.mockito.ArgumentMatchers.startsWith;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@Tag("dev")
@@ -59,7 +60,7 @@ class AihrKnowledgeResourceDownloadServiceTest {
verify(redisson).getBucket(GlobalConstants.GLOBAL_REDIS_KEY + "aihr:knowledge:resource-download:321:" + ticket);
verify(response).setHeader("Cache-Control", "no-store");
verify(response).setHeader("Referrer-Policy", "no-referrer");
verify(ossService).download(777L, response);
verify(ossService).downloadRange(777L, null, response);
}
@Test
@@ -74,6 +75,6 @@ class AihrKnowledgeResourceDownloadServiceTest {
verify(response).sendError(HttpServletResponse.SC_NOT_FOUND, "资料不存在或无权访问");
verify(redisson, never()).getBucket(anyString());
verify(ossService, never()).download(org.mockito.ArgumentMatchers.anyLong(), eq(response));
verifyNoInteractions(ossService);
}
}
@@ -36,6 +36,20 @@ class AihrDataQualityLifecycleSchemaTest {
"aihr_20260812_pipeline_run_sampling_mysql8.sql");
private static final Path GOLDEN_CALIBRATION_MIGRATION = Path.of("..", "..", "script", "sql", "update",
"aihr_20260813_golden_calibration_mysql8.sql");
private static final Path ROLLOUT_MIGRATION = Path.of("..", "..", "script", "sql", "update",
"aihr_20260815_knowledge_rollout_compat_mysql8.sql");
private static final Path VERSION_POINTER_MIGRATION = Path.of("..", "..", "script", "sql", "update",
"aihr_20260816_knowledge_version_pointers_mysql8.sql");
private static final Path REVIEW_ASSISTANCE_MIGRATION = Path.of("..", "..", "script", "sql", "update",
"aihr_20260817_knowledge_review_assistance_mysql8.sql");
private static final Path GENERATION_MIGRATION = Path.of("..", "..", "script", "sql", "update",
"aihr_20260818_knowledge_generation_revision_mysql8.sql");
private static final Path RETENTION_MIGRATION = Path.of("..", "..", "script", "sql", "update",
"aihr_20260819_knowledge_retention_deletion_mysql8.sql");
private static final Path MIGRATION_ORCHESTRATION = Path.of("..", "..", "script", "sql", "update",
"aihr_20260820_knowledge_migration_orchestration_mysql8.sql");
private static final Path SHADOW_GATE_MIGRATION = Path.of("..", "..", "script", "sql", "update",
"aihr_20260821_knowledge_shadow_gate_mysql8.sql");
private static final Path LIFECYCLE_SERVICE = Path.of("src", "main", "java", "org", "dromara", "aihr",
"knowledge", "quality", "AihrKnowledgeLifecycleService.java");
@@ -196,4 +210,78 @@ class AihrDataQualityLifecycleSchemaTest {
assertThat(sql.toLowerCase()).doesNotContain("update aihr_processing_rule");
assertThat(sql.toLowerCase()).doesNotContain("lifecycle_status = 'published'");
}
@Test
void rolloutMigrationDefaultsEveryUnconfiguredSpaceToLegacyWithoutDataMutation() throws IOException {
String sql = Files.readString(ROLLOUT_MIGRATION);
assertThat(sql).contains("`aihr_knowledge_rollout_scope`", "`aihr_knowledge_rollout_transition`",
"DEFAULT 'LEGACY'", "'LEGACY','SHADOW','ENFORCED'", "governed_collection",
"active_generation", "reviewer_id", "readiness_json");
assertThat(sql.toLowerCase()).doesNotContain("update `aihr_knowledge_attach`");
assertThat(sql.toLowerCase()).doesNotContain("insert into `aihr_knowledge_rollout_scope`");
}
@Test
void versionPointerMigrationSeparatesCandidateFromPublishedWithoutRewritingContent() throws IOException {
String sql = Files.readString(VERSION_POINTER_MIGRATION);
assertThat(sql).contains("published_version_id", "candidate_version_id", "version_status",
"revision_of_version_id", "PUBLISHED", "SUPERSEDED", "WITHDRAWN");
assertThat(sql).contains("WHEN published_version_id IS NULL AND lifecycle_status = 'PUBLISHED'",
"WHEN candidate_version_id IS NULL AND lifecycle_status IN");
assertThat(sql.toLowerCase()).doesNotContain("update `aihr_knowledge_fragment`");
assertThat(sql.toLowerCase()).doesNotContain("delete from");
}
@Test
void reviewAssistanceMigrationKeepsSuggestionsSeparateFromHumanDecisions() throws IOException {
String sql = Files.readString(REVIEW_ASSISTANCE_MIGRATION);
assertThat(sql).contains("`aihr_review_assistance`", "`aihr_review_batch`",
"`aihr_review_batch_item`", "risk_level", "policy_version", "reviewer_id", "batch_key");
assertThat(sql.toLowerCase()).doesNotContain("update aihr_data_asset");
assertThat(sql.toLowerCase()).doesNotContain("insert into aihr_review_decision");
}
@Test
void generationMigrationPreservesImmutableVersionsAndVectorLineage() throws IOException {
String sql = Files.readString(GENERATION_MIGRATION);
assertThat(sql).contains("`aihr_knowledge_generation`", "`aihr_generation_version`",
"`aihr_version_diff`", "`aihr_version_rollback`", "index_generation", "vector_point_id");
assertThat(sql.toLowerCase()).doesNotContain("delete from aihr_chunk_revision");
}
@Test
void retentionMigrationCreatesDualControlWorkflowWithoutDeletingExistingAssets() throws IOException {
String sql = Files.readString(RETENTION_MIGRATION);
assertThat(sql).contains("retention_class", "legal_hold", "delete_state",
"`aihr_deletion_request`", "requester_id", "approver_id", "execute_after",
"`aihr_reconciliation_run`", "`aihr_reconciliation_issue`");
assertThat(sql.toLowerCase()).doesNotContain("delete from aihr_data_asset");
assertThat(sql.toLowerCase()).doesNotContain("update aihr_data_asset set delete_state");
}
@Test
void migrationOrchestrationPersistsCursorBatchAndDeadLetterEvidence() throws IOException {
String sql = Files.readString(MIGRATION_ORCHESTRATION);
assertThat(sql).contains("`aihr_migration_run`", "cursor_attachment_id", "manifest_sha256",
"verified_dry_run_id", "idx_aihr_migration_verified_dry_run",
"`aihr_migration_batch`", "from_cursor", "to_cursor", "`aihr_migration_dead_letter`",
"attempt_count", "next_attempt_time");
assertThat(sql.toLowerCase()).doesNotContain("update aihr_knowledge_attach");
}
@Test
void shadowGateMigrationStoresComparisonAndHumanActivationEvidence() throws IOException {
String sql = Files.readString(SHADOW_GATE_MIGRATION);
assertThat(sql).contains("`aihr_shadow_comparison`", "top1_agreement", "overlap_at_5",
"`aihr_activation_gate`", "threshold_json", "evidence_json",
"`aihr_generation_activation`", "rollback_deadline", "ACTIVATE", "ROLLBACK");
assertThat(sql.toLowerCase()).doesNotContain("update aihr_knowledge_rollout_scope");
}
}
@@ -0,0 +1,73 @@
package org.dromara.aihr.knowledge.quality;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@Tag("dev")
class AihrKnowledgeGovernanceSafetyTest {
@Test
void retentionClassesHaveExplicitMinimumDelays() {
assertThat(AihrKnowledgeRetentionService.retentionDays("TEMPORARY")).isEqualTo(7);
assertThat(AihrKnowledgeRetentionService.retentionDays("STANDARD")).isEqualTo(90);
assertThat(AihrKnowledgeRetentionService.retentionDays("REGULATED")).isEqualTo(365);
assertThatThrownBy(() -> AihrKnowledgeRetentionService.retentionDays("PERMANENT"))
.isInstanceOf(ServiceException.class);
}
@Test
void activationThresholdsRequireEnoughHighQualityShadowEvidence() {
assertThat(AihrKnowledgeShadowRolloutService.MIN_SAMPLES).isEqualTo(20);
assertThat(AihrKnowledgeShadowRolloutService.MIN_TOP1_AGREEMENT).isGreaterThanOrEqualTo(0.9d);
assertThat(AihrKnowledgeShadowRolloutService.MIN_OVERLAP_AT_5).isGreaterThanOrEqualTo(0.8d);
assertThat(AihrKnowledgeShadowRolloutService.MAX_EMPTY_RATE).isLessThanOrEqualTo(0.01d);
}
@Test
void referencedOrActiveAssetsCannotEnterPhysicalPurge() {
assertThat(AihrKnowledgeRetentionService.canRequestPurge("DEPRECATED", "STANDARD", false, 0, 0)).isTrue();
assertThat(AihrKnowledgeRetentionService.canRequestPurge("PUBLISHED", "STANDARD", false, 0, 0)).isFalse();
assertThat(AihrKnowledgeRetentionService.canRequestPurge("DEPRECATED", "STANDARD", false, 1, 0)).isFalse();
assertThat(AihrKnowledgeRetentionService.canRequestPurge("DEPRECATED", "STANDARD", false, 0, 1)).isFalse();
assertThat(AihrKnowledgeRetentionService.canRequestPurge("DEPRECATED", "STANDARD", true, 0, 0)).isFalse();
}
@Test
void formalMigrationMustMatchACompletedDryRun() {
var completed = migrationRun("COMPLETED", true, 50, 1006L, "ALL", null);
var formal = new AihrKnowledgeMigrationOrchestrationService.CreateRun(
"formal-run", 1006L, "ALL", false, 50, 1L);
assertThat(AihrKnowledgeMigrationOrchestrationService.dryRunCovers(completed, formal)).isTrue();
assertThat(AihrKnowledgeMigrationOrchestrationService.dryRunCovers(
migrationRun("RUNNING", true, 50, 1006L, "ALL", null), formal)).isFalse();
assertThat(AihrKnowledgeMigrationOrchestrationService.dryRunCovers(
migrationRun("COMPLETED", true, 25, 1006L, "ALL", null), formal)).isFalse();
}
@Test
void reusedMigrationKeyMustKeepTheSameManifest() {
var existing = migrationRun("PLANNED", true, 50, null, "LOW_RISK", null);
assertThat(AihrKnowledgeMigrationOrchestrationService.sameManifest(existing,
new AihrKnowledgeMigrationOrchestrationService.CreateRun(
existing.runKey(), null, "LOW_RISK", true, 50, null))).isTrue();
assertThat(AihrKnowledgeMigrationOrchestrationService.sameManifest(existing,
new AihrKnowledgeMigrationOrchestrationService.CreateRun(
existing.runKey(), null, "ALL", true, 50, null))).isFalse();
}
private static AihrKnowledgeMigrationOrchestrationService.MigrationRun migrationRun(
String status, boolean dryRun, int batchSize, Long knowledgeId, String riskScope, Long verifiedDryRunId) {
return new AihrKnowledgeMigrationOrchestrationService.MigrationRun(
1L, "migration-run", knowledgeId, riskScope, dryRun, batchSize, 0L, status,
0, 0, 0, 0, "manifest", 9L, verifiedDryRunId,
LocalDateTime.now(), null, null, null);
}
}
@@ -10,6 +10,8 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.sql.ResultSet;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -24,6 +26,18 @@ import static org.mockito.Mockito.when;
@Tag("dev")
class AihrKnowledgeLegacyMigrationServiceTest {
@Test
void rawMigrationUsesTheNonMutatingCandidatePath() throws Exception {
Path source = Path.of("src", "main", "java", "org", "dromara", "aihr", "knowledge",
"quality", "AihrKnowledgeLegacyMigrationService.java");
String code = Files.readString(source);
assertFalse(code.contains("sopSeedService.reprocessStagedAttachment"));
org.assertj.core.api.Assertions.assertThat(code)
.contains("sopSeedService.stageLegacyAttachmentCandidate", "LOW_RISK", "QUARANTINE_FIRST",
"o.oss_id is not null", "o.oss_id is null");
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void previewReportsRawAvailabilityWithoutStagingAnything() throws Exception {
@@ -0,0 +1,53 @@
package org.dromara.aihr.knowledge.quality;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Tag("dev")
class AihrKnowledgeReviewAssistanceServiceTest {
@Test
void noOpenEvidenceIsLowRisk() {
assertThat(AihrKnowledgeReviewAssistanceService.riskLevel(List.of(), List.of())).isEqualTo("LOW");
}
@Test
void softWarningRequiresIndividualReviewButIsNotHighRisk() {
var issue = issue("EXACT_DUPLICATE", "WARNING", "SOFT");
assertThat(AihrKnowledgeReviewAssistanceService.riskLevel(List.of(issue), List.of()))
.isEqualTo("MEDIUM");
}
@Test
void blockingEvidenceIsCritical() {
var issue = issue("PII_DETECTED", "CRITICAL", "HARD");
assertThat(AihrKnowledgeReviewAssistanceService.riskLevel(List.of(issue), List.of()))
.isEqualTo("CRITICAL");
}
@Test
void onlyUnresolvedClaimConflictsRaiseRisk() {
var resolved = conflict("RESOLVED");
var pending = conflict("PENDING_REVIEW");
assertThat(AihrKnowledgeReviewAssistanceService.riskLevel(List.of(), List.of(resolved))).isEqualTo("LOW");
assertThat(AihrKnowledgeReviewAssistanceService.riskLevel(List.of(), List.of(pending))).isEqualTo("HIGH");
}
private static AihrKnowledgeLifecycleService.QualityIssue issue(String code, String severity, String gateType) {
return new AihrKnowledgeLifecycleService.QualityIssue(1L, code, severity, gateType, "{}", "review",
"RULE", "v1", "OPEN", LocalDateTime.now());
}
private static AihrKnowledgeClaimService.ConflictView conflict(String status) {
return new AihrKnowledgeClaimService.ConflictView(1L, "VALUE", status, "left", "right",
1L, "left.pdf", 2L, "right.pdf", "{}", null, LocalDateTime.now());
}
}
@@ -0,0 +1,35 @@
package org.dromara.aihr.knowledge.quality;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@Tag("dev")
class AihrKnowledgeRolloutServiceTest {
@Test
void servingPredicateDefaultsToLegacyAndExcludesGovernedDuplicates() {
String sql = AihrKnowledgeRolloutService.servingFragmentExistsSql("f");
assertThat(sql).contains("coalesce", "'LEGACY'", "in ('LEGACY','SHADOW')",
"published_fragment_id = f.id", "= 'ENFORCED'", "lifecycle_status = 'PUBLISHED'",
"trust_level = 'HUMAN_VERIFIED'", "dataset_code = 'production'");
}
@Test
void attachmentPredicateUsesTheSameRolloutModeAndPublishedGate() {
String sql = AihrKnowledgeRolloutService.servingAttachmentExistsSql("a");
assertThat(sql).contains("rollout_scope.mode", "in ('LEGACY','SHADOW')", "= 'ENFORCED'",
"rollout_asset.attachment_id = a.id", "rollout_asset.lifecycle_status = 'PUBLISHED'");
}
@Test
void rolloutRequiresShadowBeforeFirstEnforcementButAllowsImmediateRollback() {
assertThat(AihrKnowledgeRolloutService.canTransition("LEGACY", "SHADOW")).isTrue();
assertThat(AihrKnowledgeRolloutService.canTransition("LEGACY", "ENFORCED")).isFalse();
assertThat(AihrKnowledgeRolloutService.canTransition("SHADOW", "ENFORCED")).isTrue();
assertThat(AihrKnowledgeRolloutService.canTransition("ENFORCED", "LEGACY")).isTrue();
}
}
@@ -646,7 +646,10 @@ public class AihrSopSeedServiceTest {
assertTrue(code.contains("payload.put(\"dataset_code\", \"production\")"));
assertTrue(code.contains("payload.put(\"lifecycle_status\", \"PUBLISHED\")"));
assertTrue(code.contains("payload.put(\"trust_level\", \"HUMAN_VERIFIED\")"));
assertTrue(code.contains("qdrantProductionFilterForSpaces(allowedKnowledgeIds, null, category)"));
assertTrue(code.contains("payload.put(\"index_generation\", generation)"));
assertTrue(code.contains("qdrantProductionFilterForSpaces(knowledgeIds, null, category, generation)"));
assertTrue(code.contains("legacyQdrantCollection()"));
assertTrue(code.contains("governedQdrantCollection()"));
}
@Test
@@ -0,0 +1,66 @@
-- Space-scoped compatibility rollout for governed knowledge.
-- Additive and idempotent: absence of a scope row means LEGACY serving.
CREATE TABLE IF NOT EXISTS `aihr_knowledge_rollout_scope` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) NOT NULL,
`knowledge_id` bigint NOT NULL,
`mode` varchar(20) NOT NULL DEFAULT 'LEGACY',
`governed_collection` varchar(100) NOT NULL DEFAULT 'aihr_knowledge_governed_v1',
`active_generation` bigint NOT NULL DEFAULT 1,
`candidate_generation` bigint DEFAULT NULL,
`updated_by` bigint DEFAULT NULL,
`reason` varchar(1000) 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`),
UNIQUE KEY `uk_aihr_knowledge_rollout_scope` (`tenant_id`, `knowledge_id`),
KEY `idx_aihr_knowledge_rollout_mode` (`tenant_id`, `mode`, `knowledge_id`),
CONSTRAINT `ck_aihr_knowledge_rollout_mode` CHECK (`mode` IN ('LEGACY','SHADOW','ENFORCED')),
CONSTRAINT `ck_aihr_knowledge_rollout_generation` CHECK (`active_generation` > 0),
CONSTRAINT `ck_aihr_knowledge_rollout_candidate_generation` CHECK
(`candidate_generation` IS NULL OR `candidate_generation` > `active_generation`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
COMMENT='Per-space governed knowledge serving mode and active vector generation';
CREATE TABLE IF NOT EXISTS `aihr_knowledge_rollout_transition` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) NOT NULL,
`knowledge_id` bigint NOT NULL,
`from_mode` varchar(20) NOT NULL,
`to_mode` varchar(20) NOT NULL,
`active_generation` bigint NOT NULL,
`reviewer_id` bigint NOT NULL,
`reason` varchar(1000) NOT NULL,
`readiness_json` json DEFAULT NULL,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_aihr_knowledge_rollout_transition` (`tenant_id`, `knowledge_id`, `id`),
CONSTRAINT `ck_aihr_knowledge_rollout_transition_from` CHECK (`from_mode` IN ('LEGACY','SHADOW','ENFORCED')),
CONSTRAINT `ck_aihr_knowledge_rollout_transition_to` CHECK (`to_mode` IN ('LEGACY','SHADOW','ENFORCED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
COMMENT='Immutable human-authorized knowledge rollout transitions';
-- Match the canonical tenant collation on upgraded databases.
SET @aihr_rollout_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 @aihr_rollout_collation := COALESCE(@aihr_rollout_collation, 'utf8mb4_0900_ai_ci');
SET @aihr_rollout_ddl := CONCAT(
'ALTER TABLE `aihr_knowledge_rollout_scope` CONVERT TO CHARACTER SET utf8mb4 COLLATE ',
@aihr_rollout_collation
);
PREPARE aihr_rollout_stmt FROM @aihr_rollout_ddl;
EXECUTE aihr_rollout_stmt;
DEALLOCATE PREPARE aihr_rollout_stmt;
SET @aihr_rollout_ddl := CONCAT(
'ALTER TABLE `aihr_knowledge_rollout_transition` CONVERT TO CHARACTER SET utf8mb4 COLLATE ',
@aihr_rollout_collation
);
PREPARE aihr_rollout_stmt FROM @aihr_rollout_ddl;
EXECUTE aihr_rollout_stmt;
DEALLOCATE PREPARE aihr_rollout_stmt;
SET @aihr_rollout_ddl := NULL;
SET @aihr_rollout_collation := NULL;
@@ -0,0 +1,136 @@
-- Candidate/effective version pointers for governed knowledge.
-- Additive and idempotent. Existing current_version_id remains as a compatibility alias
-- until every read path has moved to published_version_id/candidate_version_id.
SET @aihr_column_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_data_asset'
AND column_name = 'published_version_id'
);
SET @aihr_ddl := IF(@aihr_column_exists = 0,
'ALTER TABLE `aihr_data_asset` ADD COLUMN `published_version_id` bigint DEFAULT NULL AFTER `current_version_id`',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl; EXECUTE aihr_stmt; DEALLOCATE PREPARE aihr_stmt;
SET @aihr_column_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_data_asset'
AND column_name = 'candidate_version_id'
);
SET @aihr_ddl := IF(@aihr_column_exists = 0,
'ALTER TABLE `aihr_data_asset` ADD COLUMN `candidate_version_id` bigint DEFAULT NULL AFTER `published_version_id`',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl; EXECUTE aihr_stmt; DEALLOCATE PREPARE aihr_stmt;
SET @aihr_column_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_data_asset'
AND column_name = 'published_time'
);
SET @aihr_ddl := IF(@aihr_column_exists = 0,
'ALTER TABLE `aihr_data_asset` ADD COLUMN `published_time` datetime DEFAULT NULL AFTER `candidate_version_id`',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl; EXECUTE aihr_stmt; DEALLOCATE PREPARE aihr_stmt;
SET @aihr_column_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_data_version'
AND column_name = 'version_status'
);
SET @aihr_ddl := IF(@aihr_column_exists = 0,
'ALTER TABLE `aihr_data_version` ADD COLUMN `version_status` varchar(20) NOT NULL DEFAULT ''DRAFT'' AFTER `version_no`',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl; EXECUTE aihr_stmt; DEALLOCATE PREPARE aihr_stmt;
SET @aihr_column_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_data_version'
AND column_name = 'revision_of_version_id'
);
SET @aihr_ddl := IF(@aihr_column_exists = 0,
'ALTER TABLE `aihr_data_version` ADD COLUMN `revision_of_version_id` bigint DEFAULT NULL AFTER `version_status`',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl; EXECUTE aihr_stmt; DEALLOCATE PREPARE aihr_stmt;
SET @aihr_column_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_data_version'
AND column_name = 'revision_reason'
);
SET @aihr_ddl := IF(@aihr_column_exists = 0,
'ALTER TABLE `aihr_data_version` ADD COLUMN `revision_reason` varchar(500) DEFAULT NULL AFTER `revision_of_version_id`',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl; EXECUTE aihr_stmt; DEALLOCATE PREPARE aihr_stmt;
SET @aihr_index_exists := (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'aihr_data_asset'
AND index_name = 'idx_aihr_data_asset_published_version'
);
SET @aihr_ddl := IF(@aihr_index_exists = 0,
'ALTER TABLE `aihr_data_asset` ADD KEY `idx_aihr_data_asset_published_version` (`tenant_id`, `published_version_id`)',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl; EXECUTE aihr_stmt; DEALLOCATE PREPARE aihr_stmt;
SET @aihr_index_exists := (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'aihr_data_asset'
AND index_name = 'idx_aihr_data_asset_candidate_version'
);
SET @aihr_ddl := IF(@aihr_index_exists = 0,
'ALTER TABLE `aihr_data_asset` ADD KEY `idx_aihr_data_asset_candidate_version` (`tenant_id`, `candidate_version_id`)',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl; EXECUTE aihr_stmt; DEALLOCATE PREPARE aihr_stmt;
SET @aihr_index_exists := (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'aihr_data_version'
AND index_name = 'idx_aihr_data_version_status'
);
SET @aihr_ddl := IF(@aihr_index_exists = 0,
'ALTER TABLE `aihr_data_version` ADD KEY `idx_aihr_data_version_status` (`tenant_id`, `version_status`, `id`)',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl; EXECUTE aihr_stmt; DEALLOCATE PREPARE aihr_stmt;
SET @aihr_check_exists := (
SELECT COUNT(*) FROM information_schema.table_constraints
WHERE table_schema = DATABASE() AND table_name = 'aihr_data_version'
AND constraint_name = 'ck_aihr_data_version_status' AND constraint_type = 'CHECK'
);
SET @aihr_ddl := IF(@aihr_check_exists > 0,
'ALTER TABLE `aihr_data_version` DROP CHECK `ck_aihr_data_version_status`',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl; EXECUTE aihr_stmt; DEALLOCATE PREPARE aihr_stmt;
ALTER TABLE `aihr_data_version`
ADD CONSTRAINT `ck_aihr_data_version_status`
CHECK (`version_status` IN ('DRAFT','PROCESSING','REVIEW_PENDING','QUARANTINED','APPROVED',
'PUBLISHED','SUPERSEDED','WITHDRAWN','FAILED'));
-- Backfill pointers without changing content or existing serving behavior.
UPDATE `aihr_data_asset`
SET published_version_id = CASE
WHEN published_version_id IS NULL AND lifecycle_status = 'PUBLISHED'
THEN current_version_id ELSE published_version_id END,
candidate_version_id = CASE
WHEN candidate_version_id IS NULL AND lifecycle_status IN ('REVIEW_PENDING','QUARANTINED','APPROVED')
THEN current_version_id ELSE candidate_version_id END,
published_time = CASE
WHEN published_time IS NULL AND lifecycle_status = 'PUBLISHED' THEN update_time
ELSE published_time END
WHERE current_version_id IS NOT NULL;
UPDATE `aihr_data_version` v
JOIN `aihr_data_asset` a ON a.tenant_id = v.tenant_id AND a.current_version_id = v.id
SET v.version_status = CASE a.lifecycle_status
WHEN 'PUBLISHED' THEN 'PUBLISHED'
WHEN 'DEPRECATED' THEN 'WITHDRAWN'
WHEN 'QUARANTINED' THEN 'QUARANTINED'
WHEN 'APPROVED' THEN 'APPROVED'
WHEN 'REVIEW_PENDING' THEN 'REVIEW_PENDING'
ELSE v.version_status END
WHERE v.version_status = 'DRAFT';
SET @aihr_column_exists := NULL;
SET @aihr_index_exists := NULL;
SET @aihr_check_exists := NULL;
SET @aihr_ddl := NULL;
@@ -0,0 +1,84 @@
-- Traceable review assistance and human-authorized low-risk batch review.
-- Suggestions never change asset, version, dataset or index state by themselves.
CREATE TABLE IF NOT EXISTS `aihr_review_assistance` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) NOT NULL,
`asset_id` bigint NOT NULL,
`version_id` bigint NOT NULL,
`policy_version` varchar(50) NOT NULL,
`risk_level` varchar(20) NOT NULL,
`summary_json` json NOT NULL,
`suggestion_json` json NOT NULL,
`status` varchar(20) NOT NULL DEFAULT 'GENERATED',
`generated_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`consumed_by` bigint DEFAULT NULL,
`consumed_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_review_assistance_version` (`tenant_id`, `version_id`, `policy_version`),
KEY `idx_aihr_review_assistance_queue` (`tenant_id`, `risk_level`, `status`, `generated_time`),
CONSTRAINT `ck_aihr_review_assistance_risk`
CHECK (`risk_level` IN ('LOW','MEDIUM','HIGH','CRITICAL')),
CONSTRAINT `ck_aihr_review_assistance_status`
CHECK (`status` IN ('GENERATED','CONSUMED','STALE'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
COMMENT='Versioned deterministic review package and risk suggestion';
CREATE TABLE IF NOT EXISTS `aihr_review_batch` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) NOT NULL,
`batch_key` varchar(100) NOT NULL,
`reason` varchar(1000) NOT NULL,
`requested_count` int NOT NULL,
`succeeded_count` int NOT NULL DEFAULT 0,
`failed_count` int NOT NULL DEFAULT 0,
`status` varchar(20) NOT NULL DEFAULT 'PROCESSING',
`reviewer_id` bigint NOT NULL,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`finish_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_review_batch_request` (`tenant_id`, `reviewer_id`, `batch_key`),
KEY `idx_aihr_review_batch_status` (`tenant_id`, `status`, `id`),
CONSTRAINT `ck_aihr_review_batch_status`
CHECK (`status` IN ('PROCESSING','SUCCEEDED','PARTIAL','FAILED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
COMMENT='Idempotent human-triggered low-risk review batch';
CREATE TABLE IF NOT EXISTS `aihr_review_batch_item` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) NOT NULL,
`batch_id` bigint NOT NULL,
`asset_id` bigint NOT NULL,
`version_id` bigint NOT NULL,
`risk_level` varchar(20) NOT NULL,
`decision` varchar(20) NOT NULL DEFAULT 'PUBLISH',
`status` varchar(20) NOT NULL,
`review_id` bigint DEFAULT NULL,
`error_code` varchar(100) DEFAULT NULL,
`error_message` varchar(1000) DEFAULT NULL,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_review_batch_item` (`tenant_id`, `batch_id`, `asset_id`, `version_id`),
KEY `idx_aihr_review_batch_item_asset` (`tenant_id`, `asset_id`, `version_id`),
CONSTRAINT `ck_aihr_review_batch_item_status`
CHECK (`status` IN ('SUCCEEDED','FAILED','SKIPPED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
COMMENT='Per-item result and evidence for a human review batch';
SET @aihr_review_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 @aihr_review_collation := COALESCE(@aihr_review_collation, 'utf8mb4_0900_ai_ci');
SET @aihr_review_ddl := CONCAT('ALTER TABLE `aihr_review_assistance` CONVERT TO CHARACTER SET utf8mb4 COLLATE ',
@aihr_review_collation);
PREPARE aihr_review_stmt FROM @aihr_review_ddl; EXECUTE aihr_review_stmt; DEALLOCATE PREPARE aihr_review_stmt;
SET @aihr_review_ddl := CONCAT('ALTER TABLE `aihr_review_batch` CONVERT TO CHARACTER SET utf8mb4 COLLATE ',
@aihr_review_collation);
PREPARE aihr_review_stmt FROM @aihr_review_ddl; EXECUTE aihr_review_stmt; DEALLOCATE PREPARE aihr_review_stmt;
SET @aihr_review_ddl := CONCAT('ALTER TABLE `aihr_review_batch_item` CONVERT TO CHARACTER SET utf8mb4 COLLATE ',
@aihr_review_collation);
PREPARE aihr_review_stmt FROM @aihr_review_ddl; EXECUTE aihr_review_stmt; DEALLOCATE PREPARE aihr_review_stmt;
SET @aihr_review_collation := NULL;
SET @aihr_review_ddl := NULL;
@@ -0,0 +1,153 @@
-- Version diff, rollback audit and vector-generation lineage.
-- Additive and idempotent. Historical chunks and vector generations remain recoverable.
SET @aihr_column_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_chunk_revision'
AND column_name = 'index_generation'
);
SET @aihr_ddl := IF(@aihr_column_exists = 0,
'ALTER TABLE `aihr_chunk_revision` ADD COLUMN `index_generation` bigint DEFAULT NULL AFTER `published_fragment_id`',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl; EXECUTE aihr_stmt; DEALLOCATE PREPARE aihr_stmt;
SET @aihr_check_exists := (
SELECT COUNT(*) FROM information_schema.table_constraints
WHERE table_schema = DATABASE() AND table_name = 'aihr_review_decision'
AND constraint_name = 'ck_aihr_review_decision' AND constraint_type = 'CHECK'
);
SET @aihr_ddl := IF(@aihr_check_exists > 0,
'ALTER TABLE `aihr_review_decision` DROP CHECK `ck_aihr_review_decision`',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl; EXECUTE aihr_stmt; DEALLOCATE PREPARE aihr_stmt;
ALTER TABLE `aihr_review_decision`
ADD CONSTRAINT `ck_aihr_review_decision`
CHECK (`decision` IN ('APPROVE','REJECT','QUARANTINE','WITHDRAW','DEPRECATE','ROLLBACK'));
SET @aihr_column_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_chunk_revision'
AND column_name = 'vector_point_id'
);
SET @aihr_ddl := IF(@aihr_column_exists = 0,
'ALTER TABLE `aihr_chunk_revision` ADD COLUMN `vector_point_id` varchar(100) DEFAULT NULL AFTER `index_generation`',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl; EXECUTE aihr_stmt; DEALLOCATE PREPARE aihr_stmt;
SET @aihr_index_exists := (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'aihr_chunk_revision'
AND index_name = 'idx_aihr_chunk_revision_generation'
);
SET @aihr_ddl := IF(@aihr_index_exists = 0,
'ALTER TABLE `aihr_chunk_revision` ADD KEY `idx_aihr_chunk_revision_generation` (`tenant_id`, `asset_id`, `version_id`, `index_generation`)',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl; EXECUTE aihr_stmt; DEALLOCATE PREPARE aihr_stmt;
SET @aihr_column_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_index_outbox'
AND column_name = 'index_generation'
);
SET @aihr_ddl := IF(@aihr_column_exists = 0,
'ALTER TABLE `aihr_index_outbox` ADD COLUMN `index_generation` bigint NOT NULL DEFAULT 1 AFTER `target_collection`',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl; EXECUTE aihr_stmt; DEALLOCATE PREPARE aihr_stmt;
CREATE TABLE IF NOT EXISTS `aihr_knowledge_generation` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) NOT NULL,
`knowledge_id` bigint NOT NULL,
`generation` bigint NOT NULL,
`collection_name` varchar(100) NOT NULL,
`base_generation` bigint DEFAULT NULL,
`status` varchar(20) NOT NULL DEFAULT 'BUILDING',
`version_count` int NOT NULL DEFAULT 0,
`point_count` int NOT NULL DEFAULT 0,
`manifest_sha256` char(64) DEFAULT NULL,
`created_by` bigint DEFAULT NULL,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`ready_time` datetime DEFAULT NULL,
`activated_by` bigint DEFAULT NULL,
`activated_time` datetime DEFAULT NULL,
`retire_after` datetime DEFAULT NULL,
`last_error` varchar(1000) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_knowledge_generation` (`tenant_id`, `knowledge_id`, `generation`),
KEY `idx_aihr_knowledge_generation_status` (`tenant_id`, `status`, `knowledge_id`, `generation`),
CONSTRAINT `ck_aihr_knowledge_generation_status`
CHECK (`status` IN ('BUILDING','READY','ACTIVE','RETIRED','FAILED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
COMMENT='Immutable per-space vector generation manifest';
CREATE TABLE IF NOT EXISTS `aihr_generation_version` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) NOT NULL,
`knowledge_id` bigint NOT NULL,
`generation` bigint NOT NULL,
`asset_id` bigint NOT NULL,
`version_id` bigint NOT NULL,
`content_sha256` char(64) NOT NULL,
`point_count` int NOT NULL DEFAULT 0,
`status` varchar(20) NOT NULL DEFAULT 'INCLUDED',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_generation_version` (`tenant_id`, `knowledge_id`, `generation`, `asset_id`),
KEY `idx_aihr_generation_version_lookup` (`tenant_id`, `asset_id`, `version_id`, `generation`),
CONSTRAINT `ck_aihr_generation_version_status` CHECK (`status` IN ('INCLUDED','EXCLUDED','FAILED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
COMMENT='Version membership and point count in a vector generation';
CREATE TABLE IF NOT EXISTS `aihr_version_diff` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) NOT NULL,
`asset_id` bigint NOT NULL,
`from_version_id` bigint NOT NULL,
`to_version_id` bigint NOT NULL,
`policy_version` varchar(50) NOT NULL,
`summary_json` json NOT NULL,
`chunk_diff_json` json NOT NULL,
`created_by` bigint DEFAULT NULL,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_version_diff` (`tenant_id`, `from_version_id`, `to_version_id`, `policy_version`),
KEY `idx_aihr_version_diff_asset` (`tenant_id`, `asset_id`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
COMMENT='Deterministic content and chunk-level version comparison';
CREATE TABLE IF NOT EXISTS `aihr_version_rollback` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) NOT NULL,
`asset_id` bigint NOT NULL,
`from_version_id` bigint NOT NULL,
`to_version_id` bigint NOT NULL,
`from_generation` bigint NOT NULL,
`to_generation` bigint NOT NULL,
`reason` varchar(1000) NOT NULL,
`reviewer_id` bigint NOT NULL,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_aihr_version_rollback_asset` (`tenant_id`, `asset_id`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
COMMENT='Human-authorized atomic effective-version rollback';
SET @aihr_generation_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 @aihr_generation_collation := COALESCE(@aihr_generation_collation, 'utf8mb4_0900_ai_ci');
SET @aihr_generation_ddl := CONCAT('ALTER TABLE `aihr_knowledge_generation` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_generation_collation);
PREPARE aihr_generation_stmt FROM @aihr_generation_ddl; EXECUTE aihr_generation_stmt; DEALLOCATE PREPARE aihr_generation_stmt;
SET @aihr_generation_ddl := CONCAT('ALTER TABLE `aihr_generation_version` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_generation_collation);
PREPARE aihr_generation_stmt FROM @aihr_generation_ddl; EXECUTE aihr_generation_stmt; DEALLOCATE PREPARE aihr_generation_stmt;
SET @aihr_generation_ddl := CONCAT('ALTER TABLE `aihr_version_diff` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_generation_collation);
PREPARE aihr_generation_stmt FROM @aihr_generation_ddl; EXECUTE aihr_generation_stmt; DEALLOCATE PREPARE aihr_generation_stmt;
SET @aihr_generation_ddl := CONCAT('ALTER TABLE `aihr_version_rollback` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_generation_collation);
PREPARE aihr_generation_stmt FROM @aihr_generation_ddl; EXECUTE aihr_generation_stmt; DEALLOCATE PREPARE aihr_generation_stmt;
SET @aihr_column_exists := NULL;
SET @aihr_index_exists := NULL;
SET @aihr_check_exists := NULL;
SET @aihr_ddl := NULL;
SET @aihr_generation_collation := NULL;
SET @aihr_generation_ddl := NULL;
@@ -0,0 +1,110 @@
-- Governed withdrawal, retention, dual-control purge and reconciliation evidence.
-- Additive and idempotent. No existing asset is deleted or hidden by this migration.
SET @aihr_column_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_data_asset' AND column_name = 'retention_class'
);
SET @aihr_ddl := IF(@aihr_column_exists = 0,
'ALTER TABLE `aihr_data_asset` ADD COLUMN `retention_class` varchar(20) NOT NULL DEFAULT ''STANDARD'' AFTER `data_class`',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl; EXECUTE aihr_stmt; DEALLOCATE PREPARE aihr_stmt;
SET @aihr_column_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_data_asset' AND column_name = 'legal_hold'
);
SET @aihr_ddl := IF(@aihr_column_exists = 0,
'ALTER TABLE `aihr_data_asset` ADD COLUMN `legal_hold` tinyint NOT NULL DEFAULT 0 AFTER `retention_class`',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl; EXECUTE aihr_stmt; DEALLOCATE PREPARE aihr_stmt;
SET @aihr_column_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_data_asset' AND column_name = 'delete_state'
);
SET @aihr_ddl := IF(@aihr_column_exists = 0,
'ALTER TABLE `aihr_data_asset` ADD COLUMN `delete_state` varchar(20) NOT NULL DEFAULT ''ACTIVE'' AFTER `legal_hold`',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl; EXECUTE aihr_stmt; DEALLOCATE PREPARE aihr_stmt;
CREATE TABLE IF NOT EXISTS `aihr_deletion_request` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) NOT NULL,
`request_key` varchar(100) NOT NULL,
`asset_id` bigint NOT NULL,
`version_id` bigint DEFAULT NULL,
`request_type` varchar(20) NOT NULL DEFAULT 'PURGE',
`retention_class` varchar(20) NOT NULL,
`reason` varchar(1000) NOT NULL,
`impact_snapshot_json` json NOT NULL,
`status` varchar(20) NOT NULL DEFAULT 'PENDING',
`requester_id` bigint NOT NULL,
`requested_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`approver_id` bigint DEFAULT NULL,
`approval_reason` varchar(1000) DEFAULT NULL,
`approved_time` datetime DEFAULT NULL,
`execute_after` datetime NOT NULL,
`executed_by` bigint DEFAULT NULL,
`executed_time` datetime DEFAULT NULL,
`last_error` varchar(1000) DEFAULT NULL,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_deletion_request_key` (`tenant_id`, `requester_id`, `request_key`),
KEY `idx_aihr_deletion_request_status` (`tenant_id`, `status`, `execute_after`),
KEY `idx_aihr_deletion_request_asset` (`tenant_id`, `asset_id`, `id`),
CONSTRAINT `ck_aihr_deletion_request_status`
CHECK (`status` IN ('PENDING','APPROVED','REJECTED','EXECUTING','EXECUTED','FAILED','CANCELLED')),
CONSTRAINT `ck_aihr_deletion_request_type` CHECK (`request_type` IN ('PURGE'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
COMMENT='Dual-control physical deletion request and durable tombstone';
CREATE TABLE IF NOT EXISTS `aihr_reconciliation_run` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) NOT NULL,
`scope_type` varchar(20) NOT NULL,
`scope_id` bigint DEFAULT NULL,
`status` varchar(20) NOT NULL,
`mysql_assets` int NOT NULL DEFAULT 0,
`mysql_fragments` int NOT NULL DEFAULT 0,
`qdrant_points` bigint DEFAULT NULL,
`issue_count` int NOT NULL DEFAULT 0,
`summary_json` json NOT NULL,
`created_by` bigint DEFAULT NULL,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`finish_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_aihr_reconciliation_run` (`tenant_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
COMMENT='Cross-store knowledge reconciliation evidence';
CREATE TABLE IF NOT EXISTS `aihr_reconciliation_issue` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) NOT NULL,
`run_id` bigint NOT NULL,
`asset_id` bigint DEFAULT NULL,
`issue_code` varchar(60) NOT NULL,
`severity` varchar(20) NOT NULL,
`evidence_json` json NOT NULL,
`status` varchar(20) NOT NULL DEFAULT 'OPEN',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_aihr_reconciliation_issue` (`tenant_id`, `run_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
COMMENT='Actionable MySQL, OSS and vector lineage mismatch';
SET @aihr_retention_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 @aihr_retention_collation := COALESCE(@aihr_retention_collation, 'utf8mb4_0900_ai_ci');
SET @aihr_retention_ddl := CONCAT('ALTER TABLE `aihr_deletion_request` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_retention_collation);
PREPARE aihr_retention_stmt FROM @aihr_retention_ddl; EXECUTE aihr_retention_stmt; DEALLOCATE PREPARE aihr_retention_stmt;
SET @aihr_retention_ddl := CONCAT('ALTER TABLE `aihr_reconciliation_run` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_retention_collation);
PREPARE aihr_retention_stmt FROM @aihr_retention_ddl; EXECUTE aihr_retention_stmt; DEALLOCATE PREPARE aihr_retention_stmt;
SET @aihr_retention_ddl := CONCAT('ALTER TABLE `aihr_reconciliation_issue` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_retention_collation);
PREPARE aihr_retention_stmt FROM @aihr_retention_ddl; EXECUTE aihr_retention_stmt; DEALLOCATE PREPARE aihr_retention_stmt;
SET @aihr_column_exists := NULL;
SET @aihr_ddl := NULL;
SET @aihr_retention_collation := NULL;
SET @aihr_retention_ddl := NULL;
@@ -0,0 +1,108 @@
-- Resumable legacy migration manifests, batches and dead-letter evidence.
CREATE TABLE IF NOT EXISTS `aihr_migration_run` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) NOT NULL,
`run_key` varchar(100) NOT NULL,
`knowledge_id` bigint DEFAULT NULL,
`risk_scope` varchar(20) NOT NULL DEFAULT 'ALL',
`dry_run` tinyint NOT NULL DEFAULT 1,
`batch_size` int NOT NULL,
`cursor_attachment_id` bigint NOT NULL DEFAULT 0,
`status` varchar(20) NOT NULL DEFAULT 'PLANNED',
`discovered_count` int NOT NULL DEFAULT 0,
`staged_count` int NOT NULL DEFAULT 0,
`quarantined_count` int NOT NULL DEFAULT 0,
`failed_count` int NOT NULL DEFAULT 0,
`manifest_sha256` char(64) NOT NULL,
`requested_by` bigint NOT NULL,
`verified_dry_run_id` bigint DEFAULT NULL,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`start_time` datetime DEFAULT NULL,
`finish_time` datetime DEFAULT NULL,
`last_error` varchar(1000) DEFAULT NULL,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_migration_run_key` (`tenant_id`, `requested_by`, `run_key`),
KEY `idx_aihr_migration_run_status` (`tenant_id`, `status`, `id`),
CONSTRAINT `ck_aihr_migration_run_status`
CHECK (`status` IN ('PLANNED','RUNNING','COMPLETED','COMPLETED_WITH_ERRORS','FAILED','CANCELLED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
COMMENT='Resumable governed migration manifest';
CREATE TABLE IF NOT EXISTS `aihr_migration_batch` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) NOT NULL,
`run_id` bigint NOT NULL,
`batch_no` int NOT NULL,
`from_cursor` bigint NOT NULL,
`to_cursor` bigint NOT NULL,
`status` varchar(20) NOT NULL,
`discovered_count` int NOT NULL DEFAULT 0,
`staged_count` int NOT NULL DEFAULT 0,
`reparsed_count` int NOT NULL DEFAULT 0,
`quarantined_count` int NOT NULL DEFAULT 0,
`failed_count` int NOT NULL DEFAULT 0,
`result_json` json NOT NULL,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`finish_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_migration_batch` (`tenant_id`, `run_id`, `batch_no`),
KEY `idx_aihr_migration_batch_run` (`tenant_id`, `run_id`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
COMMENT='Migration cursor checkpoint and batch evidence';
CREATE TABLE IF NOT EXISTS `aihr_migration_dead_letter` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) NOT NULL,
`run_id` bigint NOT NULL,
`attachment_id` bigint NOT NULL,
`status` varchar(20) NOT NULL DEFAULT 'OPEN',
`attempt_count` int NOT NULL DEFAULT 1,
`last_error` varchar(1000) DEFAULT NULL,
`next_attempt_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`resolved_asset_id` bigint 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`),
UNIQUE KEY `uk_aihr_migration_dead_letter` (`tenant_id`, `run_id`, `attachment_id`),
KEY `idx_aihr_migration_dead_letter_retry` (`tenant_id`, `status`, `next_attempt_time`),
CONSTRAINT `ck_aihr_migration_dead_letter_status` CHECK (`status` IN ('OPEN','RETRYING','RESOLVED','ABANDONED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
COMMENT='Explicit migration retry and dead-letter queue';
SET @aihr_migration_column_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_migration_run'
AND column_name = 'verified_dry_run_id'
);
SET @aihr_migration_ddl := IF(@aihr_migration_column_exists = 0,
'ALTER TABLE `aihr_migration_run` ADD COLUMN `verified_dry_run_id` bigint DEFAULT NULL AFTER `requested_by`',
'SELECT 1');
PREPARE aihr_migration_stmt FROM @aihr_migration_ddl; EXECUTE aihr_migration_stmt; DEALLOCATE PREPARE aihr_migration_stmt;
SET @aihr_migration_index_exists := (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'aihr_migration_run'
AND index_name = 'idx_aihr_migration_verified_dry_run'
);
SET @aihr_migration_ddl := IF(@aihr_migration_index_exists = 0,
'ALTER TABLE `aihr_migration_run` ADD KEY `idx_aihr_migration_verified_dry_run` (`tenant_id`, `verified_dry_run_id`)',
'SELECT 1');
PREPARE aihr_migration_stmt FROM @aihr_migration_ddl; EXECUTE aihr_migration_stmt; DEALLOCATE PREPARE aihr_migration_stmt;
SET @aihr_migration_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 @aihr_migration_collation := COALESCE(@aihr_migration_collation, 'utf8mb4_0900_ai_ci');
SET @aihr_migration_ddl := CONCAT('ALTER TABLE `aihr_migration_run` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_migration_collation);
PREPARE aihr_migration_stmt FROM @aihr_migration_ddl; EXECUTE aihr_migration_stmt; DEALLOCATE PREPARE aihr_migration_stmt;
SET @aihr_migration_ddl := CONCAT('ALTER TABLE `aihr_migration_batch` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_migration_collation);
PREPARE aihr_migration_stmt FROM @aihr_migration_ddl; EXECUTE aihr_migration_stmt; DEALLOCATE PREPARE aihr_migration_stmt;
SET @aihr_migration_ddl := CONCAT('ALTER TABLE `aihr_migration_dead_letter` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_migration_collation);
PREPARE aihr_migration_stmt FROM @aihr_migration_ddl; EXECUTE aihr_migration_stmt; DEALLOCATE PREPARE aihr_migration_stmt;
SET @aihr_migration_collation := NULL;
SET @aihr_migration_ddl := NULL;
SET @aihr_migration_column_exists := NULL;
SET @aihr_migration_index_exists := NULL;
@@ -0,0 +1,80 @@
-- Shadow retrieval evidence and explicit generation activation gates.
CREATE TABLE IF NOT EXISTS `aihr_shadow_comparison` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) NOT NULL,
`knowledge_id` bigint NOT NULL,
`generation` bigint NOT NULL,
`query_sha256` char(64) NOT NULL,
`query_text` varchar(1000) NOT NULL,
`legacy_result_json` json NOT NULL,
`governed_result_json` json NOT NULL,
`legacy_top1_fragment_id` bigint DEFAULT NULL,
`governed_top1_fragment_id` bigint DEFAULT NULL,
`top1_agreement` tinyint NOT NULL DEFAULT 0,
`overlap_at_5` decimal(8,6) NOT NULL DEFAULT 0,
`governed_empty` tinyint NOT NULL DEFAULT 0,
`latency_ms` int NOT NULL DEFAULT 0,
`source` varchar(30) NOT NULL DEFAULT 'MANUAL_SAMPLE',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_shadow_comparison` (`tenant_id`, `knowledge_id`, `generation`, `query_sha256`),
KEY `idx_aihr_shadow_comparison_metrics` (`tenant_id`, `knowledge_id`, `generation`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
COMMENT='Legacy versus governed retrieval comparison without serving impact';
CREATE TABLE IF NOT EXISTS `aihr_activation_gate` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) NOT NULL,
`knowledge_id` bigint NOT NULL,
`candidate_generation` bigint NOT NULL,
`status` varchar(20) NOT NULL,
`sample_count` int NOT NULL,
`top1_agreement_rate` decimal(8,6) NOT NULL,
`overlap_at_5_avg` decimal(8,6) NOT NULL,
`governed_empty_rate` decimal(8,6) NOT NULL,
`blocking_asset_count` int NOT NULL,
`open_critical_issue_count` int NOT NULL,
`dead_letter_count` int NOT NULL,
`generation_status` varchar(20) NOT NULL,
`threshold_json` json NOT NULL,
`evidence_json` json NOT NULL,
`evaluated_by` bigint NOT NULL,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_aihr_activation_gate` (`tenant_id`, `knowledge_id`, `candidate_generation`, `id`),
CONSTRAINT `ck_aihr_activation_gate_status` CHECK (`status` IN ('PASSED','BLOCKED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
COMMENT='Immutable evidence for human generation activation';
CREATE TABLE IF NOT EXISTS `aihr_generation_activation` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) NOT NULL,
`knowledge_id` bigint NOT NULL,
`from_generation` bigint NOT NULL,
`to_generation` bigint NOT NULL,
`gate_id` bigint NOT NULL,
`action` varchar(20) NOT NULL,
`reason` varchar(1000) NOT NULL,
`reviewer_id` bigint NOT NULL,
`rollback_deadline` datetime DEFAULT NULL,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_aihr_generation_activation` (`tenant_id`, `knowledge_id`, `id`),
CONSTRAINT `ck_aihr_generation_activation_action` CHECK (`action` IN ('ACTIVATE','ROLLBACK'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
COMMENT='Human generation activation and rollback audit';
SET @aihr_shadow_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 @aihr_shadow_collation := COALESCE(@aihr_shadow_collation, 'utf8mb4_0900_ai_ci');
SET @aihr_shadow_ddl := CONCAT('ALTER TABLE `aihr_shadow_comparison` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_shadow_collation);
PREPARE aihr_shadow_stmt FROM @aihr_shadow_ddl; EXECUTE aihr_shadow_stmt; DEALLOCATE PREPARE aihr_shadow_stmt;
SET @aihr_shadow_ddl := CONCAT('ALTER TABLE `aihr_activation_gate` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_shadow_collation);
PREPARE aihr_shadow_stmt FROM @aihr_shadow_ddl; EXECUTE aihr_shadow_stmt; DEALLOCATE PREPARE aihr_shadow_stmt;
SET @aihr_shadow_ddl := CONCAT('ALTER TABLE `aihr_generation_activation` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_shadow_collation);
PREPARE aihr_shadow_stmt FROM @aihr_shadow_ddl; EXECUTE aihr_shadow_stmt; DEALLOCATE PREPARE aihr_shadow_stmt;
SET @aihr_shadow_collation := NULL;
SET @aihr_shadow_ddl := NULL;