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)));
@@ -1056,9 +1220,16 @@ 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 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());
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());
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());
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;
+35
View File
@@ -299,8 +299,43 @@ POST /api/knowledge/quality/assets/{assetId}/publish
POST /api/knowledge/quality/assets/{assetId}/withdraw
GET /api/knowledge/quality/legacy/preview?afterAttachmentId=0&limit=50
POST /api/knowledge/quality/legacy/stage?afterAttachmentId=0&limit=50
GET /api/knowledge/quality/assets/{assetId}/versions
GET /api/knowledge/quality/assets/{assetId}/versions/{versionId}
GET /api/knowledge/quality/assets/{assetId}/versions/diff?fromVersionId=1&toVersionId=2
GET /api/knowledge/quality/assets/{assetId}/review-package
POST /api/knowledge/quality/assets/review-batches
POST /api/knowledge/quality/assets/{assetId}/rollback
GET /api/knowledge/quality/assets/{assetId}/impact
POST /api/knowledge/quality/assets/{assetId}/purge-requests
GET /api/knowledge/quality/purge-requests
POST /api/knowledge/quality/purge-requests/{requestId}/decision
POST /api/knowledge/quality/purge-requests/{requestId}/execute
POST /api/knowledge/quality/reconciliation-runs
POST /api/knowledge/quality/migration-runs
GET /api/knowledge/quality/migration-runs
POST /api/knowledge/quality/migration-runs/{runId}/next
GET /api/knowledge/quality/migration-runs/{runId}/dead-letters
POST /api/knowledge/quality/migration-dead-letters/{deadLetterId}/retry
GET /api/knowledge/quality/rollout/scopes
POST /api/knowledge/quality/rollout/spaces/{knowledgeId}/transition
POST /api/knowledge/quality/rollout/spaces/{knowledgeId}/generation-builds
POST /api/knowledge/quality/rollout/spaces/{knowledgeId}/generation-builds/refresh
POST /api/knowledge/quality/rollout/spaces/{knowledgeId}/shadow-comparisons
POST /api/knowledge/quality/rollout/spaces/{knowledgeId}/activation-gates
POST /api/knowledge/quality/rollout/spaces/{knowledgeId}/activate
POST /api/knowledge/quality/rollout/spaces/{knowledgeId}/generation-rollback
```
资料资产使用独立的 `published_version_id` 与 `candidate_version_id`。上传和重处理只推进候选指针,不能覆盖正在服务的生效版;发布在同一事务内切换生效指针、生产数据集成员和索引 outbox,旧版本转为 `SUPERSEDED` 并保留不可变片段。版本差异按正文 hash 与 chunk 序号生成可复算证据,回滚必须由登录审核人填写原因,且目标必须是曾经发布、仍有生产成员证据和不可变片段的历史版本。
审核包只提供风险、重复、冲突、质量问题和建议动作,不代替人工决定。低风险批量发布必须由人工显式发起、携带幂等 `batchKey`,逐项重新校验候选版本;`MEDIUM/HIGH/CRITICAL` 不进入批量发布。并发审核通过 `expectedVersionId` 拒绝已经换过候选版的陈旧操作。
撤回是可恢复的在线下线:立即停用生产成员并排队删除当前 generation 的向量,但保留版本和片段供审计/回滚。物理清理只能针对已撤回资产,先生成影响快照,再进入 `PENDING -> APPROVED -> EXECUTING -> EXECUTED/FAILED`;申请人与批准/执行人必须不同,`legal_hold`、`PERMANENT` 或未到 `execute_after` 一律拒绝。保留期为 `TEMPORARY=7`、`STANDARD=90`、`REGULATED=365` 天。独占 OSS、MySQL 版本/片段/证据和对应向量按审计请求清理,`aihr_deletion_request` 永久保留为墓碑;`reconciliation-runs` 用于发现 MySQL、OSS 元数据、向量计数和 chunk 向量血缘漂移。
历史补链使用迁移清单而不是无状态循环调用。每个 `migration-run` 冻结租户、可选知识空间、风险范围、dry-run、批大小和 manifest hash;`next` 只推进已持久化游标并记录批次证据。`LOW_RISK` 只扫描租户可验证 OSS 原件,`QUARANTINE_FIRST` 只扫描缺失原件且需要隔离的资料,`ALL` 扫描全部候选。失败附件进入显式 dead letter,最多按退避策略重试,不能因为单项失败回滚或隐藏仍由 LEGACY 服务的线上资料。正式任务必须提交 `verifiedDryRunId`,服务端只接受已完成且知识空间、风险范围、批大小完全一致的演练;管理端创建正式任务后仍需人工逐批推进,不会自动修改资料。
空间发布固定为 `LEGACY -> SHADOW -> ENFORCED`。SHADOW 仍服务旧链路,候选 generation 在独立 collection/generation 中全量构建;管理端可记录真实问题的双链路对照。启用门禁要求候选 generation 为 `READY`、至少 20 个样本、Top1 一致率不低于 90%、Top5 平均重合率不低于 80%、治理结果空集率不高于 1%,且无覆盖缺口、开放关键问题或索引死信。旧 transition 接口不能直接进入 ENFORCED;只有人工使用最新 `PASSED` gate 调用 `activate` 才能切换。原 generation 保留 7 天回退窗口,回退后恢复 SHADOW。
`publish` 必须带人工审核意见、用途、来源权威级别和来源版本。请求中的 `acceptedReasonCodes` 只能确认当前版本仍开放的软提示,服务端会再次统计开放问题,遗漏任何一项都拒绝发布;`HARD` 问题不能在发布动作中接受,必须修订原资料并生成新版本,或通过后续独立的显式纠错流程解决。规范性知识仅接受 `FORMAL_POLICY / COMPANY_POLICY / REGULATION / APPROVED_SOP` 且版本非空;同名已发布资料内容变化会产生 `VERSION_CONFLICT`,规范性知识必须再传 `supersedesAssetId`,服务端验证同租户、同来源名和旧资产仍已发布后,在同一事务内撤回旧版本并发布新版本。
规范化采用版本化的 NFKC、换行、控制字符和空白处理;解析正文和规范化正文作为不可变审计证据保留,不作为下游生产内容。`privacy-redaction-v1` 从规范化正文生成独立的 `redacted_content/redacted_source_name`,记录派生 hash、分类计数、隐私状态和规则版本,并对派生正文及全部派生 chunk 复扫。只有 `CLEAN/REDACTED` 派生版本参与结构化切片、异步语义分析、模型调用、发布和检索;重复文件复用的历史摘要、模型新生成的摘要/标签/归类理由以及上传 snippet 也必须在返回或保存前走同一脱敏规则,不能因 OSS 对象复用而绕过。残留敏感模式写入 `BLOCKED / PII_DETECTED` 并拒绝发布,已完成替换写入 `PII_REDACTED` 证据。`GET /assets/{assetId}/privacy-preview` 只返回脱敏文件名、分类计数和截断后的脱敏正文,不返回原始正文。
+14 -4
View File
@@ -14,6 +14,8 @@
>
> 2026-07-30 增量新增并发布五通道处理人配置:只有 `superadmin` 可查看当前租户处理人、检索合格候选并增删角色绑定;服务端排除 APP 用户、超级管理员、停用/删除和跨租户账号,写请求复核 `expectedTenantId`。生产五个角色均为 `2/2`,本通道正例、跨通道拒绝和一次正式回复已完成 `5/5`,2026-07-31 纳入受控内部公测签认。
>
> 2026-08-03 当前工作树完成数字资产全生命周期治理的本地实现与验证,尚未部署:先以按空间 `LEGACY/SHADOW/ENFORCED` 兼容层保护线上旧资料,再依次提供候选/生效双指针、不可变版本与审核辅助、generation 向量血缘、撤回与回滚、保留/法务冻结/异人清理、可续跑历史补链、影子对照和人工启用门禁。生产不得跳过兼容层直接执行七阶段,也不得在发布窗口执行物理清理或自动切换现有空间。
>
> 本文只维护当前结论、证据层级和剩余门槛。逐提交发布流水已从当前真相文档移除,需要追溯时使用 Git 历史;不得再用 2026-07-14/15 的“尚未发布”记录判断现网。
## 1. 当前结论
@@ -26,10 +28,10 @@
| 层级 | 当前状态 | 证据与边界 |
|---|---|---|
| 已实现 | 是 | 已有阶段一主功能;当前工作树另实现大喇叭投放控制、反馈转人工、正式学习任务/随机组卷、案例经验治理、原生异常恢复和上传容量门禁;接口事实见 [API_INTEGRATION.md](API_INTEGRATION.md) |
| 本地自动化/API 验证 | 是 | 当前工作树管理端生产构建、移动端类型检查和 `216/216` 单测通过;后端 Surefire 共 `107` 份报告、`838` 项测试,失败/错误/跳过均为 `0`;迁移静态清单通过,Docker/MySQL 幂等执行未完成 |
| 本地浏览器/视觉验证 | 部分 | `390×844` 已实际点击员工“今日/练/问”、大喇叭登录门禁和直通车登录门禁,无横向溢出或重叠;管理端认证后的案例发布弹窗因后端/MySQL 未运行而未验证。既有生产公司消息回归和 Android ASR 证据不外推到本批增量 |
| 已部署 | 部分 | 2026-07-25 至 2026-07-31 的既有生产基线已部署;当前工作树的四个 `aihr_20260802_*` 迁移及对应后端、管理端和移动端增量均未部署 |
| 已实现 | 是 | 已有阶段一主功能;当前工作树另实现大喇叭投放控制、反馈转人工、正式学习任务/随机组卷、案例经验治理、原生异常恢复、上传容量门禁,以及数字资产兼容放量、版本、审核、向量代次、撤回/回滚、保留清理、历史迁移和影子启用全链路;接口事实见 [API_INTEGRATION.md](API_INTEGRATION.md) |
| 本地自动化/API 验证 | 是 | 当前工作树管理端生产构建通过;后端完整测试 `851/851`,失败/错误/跳过均为 `0`;发布预检范围契约通过,生命周期迁移已在现有 MySQL 连续执行两次且资产数保持 `104`,未创建清理或迁移任务 |
| 本地浏览器/视觉验证 | 部分 | `390×844` 已实际点击员工“今日/练/问”、大喇叭登录门禁和直通车登录门禁,无横向溢出或重叠;2026-08-03 已在当前工作树管理端登录并实际打开空间放量、迁移/清理、版本审核辅助和影子启用对话框,桌面布局无明显重叠或截断,未提交任何状态变更。窄屏管理端本轮未复核 |
| 已部署 | 部分 | 2026-07-25 至 2026-07-31 的既有生产基线已部署;当前工作树的 `aihr_20260815_*` 至 `aihr_20260821_*` 生命周期迁移及对应后端、管理端均未部署,线上资料未发生本批变更 |
| 生产基础验证 | 是 | 服务 `active`;根站、租户接口和移动首页 API 正常;媒体重试列/索引契约通过,4 个历史零片段任务均恢复为已完成且有片段/向量。当前未执行包含移动 H5 的完整包匹配 |
| 生产完整业务验收 | 部分 | 公司消息已完成认证态全员文件消息和直通车闭环,单台 Android 标准基座已完成生产 ASR;训练写入、主管复盘、定向人群正反例、正式处理人绑定、其他真机媒体与严格试点窗口仍未完成 |
@@ -43,6 +45,14 @@
- 运行门禁:资料暂存盘默认要求至少 10GiB 可用空间,生产 HTTP 请求启用 P50/P95/P99 指标;约 600 名用户不是 600 并发,压测模型和通过阈值仍待业务、实施和运维签认。
- 发布边界:四个正式迁移已纳入 Runbook;本地 Docker 未运行,未执行 MySQL 首次/重复迁移、完整后端认证 API、签名 APK、真机或生产验证。
### 2026-08-03 数字资产生命周期本地实现边界
- 执行顺序已经重新编排,不是把自动化辅助作为七阶段之外的平行项目:阶段 0 兼容层必须先落地,随后七阶段能力按依赖进入同一发布计划;系统先生成清单、风险、差异、影响和建议,人工只负责业务判断、异人批准和最终启用。
- 既有资料默认继续由 `LEGACY` 服务。历史补链必须先创建固定范围的 dry-run,只有无失败完成且知识空间、风险范围、批大小完全匹配的演练,才能生成正式任务;正式任务仍需人工逐批推进,单项失败进入 dead letter,不中断旧读链路。
- 新上传或修订只推进候选版本,生效版本继续提供检索;发布、撤回、回滚和 generation 切换均保留审计证据。日常删除语义为可恢复撤回,物理清理另受保留期、法务冻结、引用影响和异人控制约束。
- 生产启用固定为单空间 `LEGACY -> SHADOW -> ENFORCED`。SHADOW 期间构建隔离候选 generation 并记录真实问题对照;覆盖、索引、质量和样本门禁通过后,才允许人工确认启用,并保留七天 generation 回退窗口。
- 本地浏览器仅执行只读查看和打开对话框。验收前后数据库均为资产 `104`、非活动删除状态 `0`、清理申请 `0`、迁移任务 `0`、影子样本 `0`、generation 启用 `0`;已有一个本地 SHADOW 空间保持不变。以上不能外推为生产部署或线上迁移完成。
### 2026-07-29 资料处理媒体恢复发布边界
历史图片/视频在视觉模型不接受原始分辨率/编码,或模型、ASR、关键帧链路没有返回可检索文字时,旧逻辑会留下状态 0、片段 0 的附件,页面长期显示“等待解析”,却没有从原件重新入队的入口。当前实现先对图片纠正方向、缩放和重编码;异步队列只有生成至少一个片段才进入完成态,零片段明确保持可重试失败。管理端可调用 `POST /api/knowledge/doc/processing-tasks/{attachmentId}/retry`,服务端按当前租户从原 OSS 对象重新暂存,通过附件状态 CAS 防止重复入队,并用 `source_attach_id` 关联新队列条目。
+11
View File
@@ -166,6 +166,13 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260812_pipeline_run_sampling_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260813_golden_calibration_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260814_knowledge_privacy_derivative_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260815_knowledge_rollout_compat_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260816_knowledge_version_pointers_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260817_knowledge_review_assistance_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260818_knowledge_generation_revision_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260819_knowledge_retention_deletion_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260820_knowledge_migration_orchestration_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260821_knowledge_shadow_gate_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_personal_knowledge_mysql8.sql
```
@@ -173,6 +180,10 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_perso
随后按 `20260808 -> 20260809 -> 20260810 -> 20260811 -> 20260812 -> 20260813 -> 20260814` 执行质量监控、人工术语修订、AI 案例来源、规则演进、处理批次、黄金集校准和隐私派生迁移。`20260811` 只增加规则草稿、状态审计、影子对照和抽样复核;`20260812` 只增加 `pipeline_run` 与自动生成待人工复核样本的调度,不启用规则执法,不改变任何资产生命周期。`20260814` 只增加独立脱敏正文、脱敏文件名、派生 hash、隐私状态、规则版本和复扫摘要字段,历史版本默认保持 `NOT_PROCESSED`,不得通过 SQL 伪造脱敏完成或自动获批。完整迁移后仍须最后执行排序规则兼容迁移。
生命周期兼容发布继续按 `20260815 -> 20260816 -> 20260817 -> 20260818 -> 20260819 -> 20260820 -> 20260821` 执行:先建立按空间的 `LEGACY/SHADOW/ENFORCED` 兼容范围,再增加候选/生效双指针、审核辅助、不可变 generation 与回滚证据、保留和异人删除、历史迁移清单、影子指标与启用门禁。七个脚本均为增量结构迁移;不得在 SQL 阶段自动发布、撤回、删除或切换现有空间。迁移完成后所有空间仍应为 LEGACY 或保持迁移前显式模式,历史资料继续可见。生产发布必须先只读统计资产、版本、生产成员、fragment、OSS 和 Qdrant 数量并备份;先以单空间 dry-run 和 SHADOW 验证,达到门禁后由人工启用。正式迁移任务必须通过 `verified_dry_run_id` 绑定同知识空间、同风险范围、同批大小且无失败的已完成演练;创建正式任务不自动推进批次。任何补链失败都停留在迁移 dead letter,不得切断旧读路径。
生产物理删除不是部署步骤,也不得与 schema/JAR 发布同窗口批量执行。日常纠错只做 `withdraw`;`purge` 必须有业务/法务确认的保留分类、影响快照、异人批准和已到期 `execute_after`。发布后先运行 reconciliation 并处理不一致,再考虑清理。ENFORCED 启用后旧 generation 保留七天;窗口内指标恶化可从管理端回退上一代,超过窗口必须重新构建候选 generation 和重新通过门禁。
8 月 1 日受控内部试点的正式来源注册是独立的业务数据变更,不随通用 schema 自动执行。先逐一核对附件 `id + doc_id`、原文件 SHA-256、发文号/版本、生效日期和适用范围,再由已授权负责人执行 `backend/script/sql/ops/aihr_20260730_aug1_formal_source_registry_mysql8.sql`。执行后必须只读确认恰好 10 条 `FORMAL_POLICY + APPROVED` 记录;任何缺失都保持无正式依据,不得把访谈、经验萃取、AI 生成内容或草稿补进白名单。
顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;五岗位题库之后的内容运营迁移为每个场景补齐唯一的初始 Rubric、五个稳定评分维度与训练时评分规则快照列,并只修正仍处于待训练状态的旧每日题场景编号,不重写已完成记录。紧随其后的成长目录迁移只在既有场景和会话表上增加岗位/层级/能力项/审核状态与会话快照,以及复盘会话专用的 `growth_confirmation_level/growth_confirmed_by/growth_confirmed_time`;它只允许记录主管对当前会话下一训练层级的确认,不改写原训练快照或人事结论,并补齐/修复派发请求的 `(tenant_id,request_key,ext_party_id)` 唯一键与最近内容过滤索引。它保留既有启停可用性和历史会话,不把迁移结果写成业务内容签字。生产环境不得设置 `AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP=true`:应用请求只读校验表、列和关键索引,任何缺失都必须先执行本节正式 SQL,不能由任一用户请求触发补列、补索引或状态回填。岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;正式知识来源治理迁移只创建附件级权威类型、生命周期、版本、生效期、适用范围和原文件哈希契约,不自动批准任何历史附件。移动端政策问答只使用有效的 `FORMAL_POLICY + APPROVED` 注册项,治理表缺失、来源未批准、已失效、版本或哈希为空时均按无正式依据关闭。银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。直通车迁移创建点对点反馈表,并给正式租户 `000000` 幂等预置总裁、财务、人力、审计、运营五个处理角色;人员仍须由管理员按职责分配,业务匿名不抹除内部审计身份。公司文件迁移创建异步提炼表并给消息增加可空 `attachment_id`,不迁移或公开已有文件;紧随其后的多岗位解读迁移只在附件表增加生成状态、经引用校验的结构化结果和规则版本,并建立异步队列索引,不回填旧文件、不改变文件 READY 或消息发布状态。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。Agent 迁移只新增最小运行审计表,不保存问题、答案、密钥或模型推理。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。
+228
View File
@@ -187,6 +187,129 @@ export type LegacyStagePreview = {
moreAvailable: boolean;
};
export type KnowledgeRolloutMode = 'LEGACY' | 'SHADOW' | 'ENFORCED';
export type KnowledgeRolloutScope = {
knowledgeId: number;
spaceCode: string;
spaceName: string;
mode: KnowledgeRolloutMode;
governedCollection: string;
activeGeneration: number;
candidateGeneration?: number;
updatedBy?: number;
reason?: string;
updatedTime?: string;
readyAttachments: number;
enforcementBlockers: number;
};
export type KnowledgeVersion = {
id: number;
versionNo: number;
versionStatus: string;
revisionOfVersionId?: number;
revisionReason?: string;
contentSha256: string;
parserName?: string;
parserVersion?: string;
semanticAnalysisStatus: string;
privacyStatus: string;
published: boolean;
candidate: boolean;
chunkCount: number;
openIssueCount: number;
createTime: string;
};
export type ReviewPackage = {
assetId: number;
versionId: number;
versionNo: number;
riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
batchEligible: boolean;
summary: {
sourceName: string;
contentPreview: string;
headings: string[];
chunkCount: number;
issueCount: number;
duplicateCount: number;
conflictCount: number;
};
suggestion: { decision: string; reasonCodes: string[]; recommendedActions: string[]; disclaimer: string };
};
export type DeletionImpact = {
assetId: number;
lifecycleStatus: string;
retentionClass: string;
legalHold: boolean;
chunks: number;
publishedFragments: number;
queryEvidence: number;
learningTasks: number;
broadcasts: number;
generations: number;
canRequestPurge: boolean;
blockers: string[];
};
export type PurgeRequest = {
id: number;
requestKey: string;
assetId: number;
retentionClass: string;
reason: string;
status: string;
requesterId: number;
requestedTime: string;
approverId?: number;
approvalReason?: string;
executeAfter: string;
executedTime?: string;
lastError?: string;
};
export type MigrationRun = {
id: number;
runKey: string;
knowledgeId?: number;
riskScope: string;
dryRun: boolean;
batchSize: number;
cursorAttachmentId: number;
status: string;
discoveredCount: number;
stagedCount: number;
quarantinedCount: number;
failedCount: number;
manifestSha256: string;
verifiedDryRunId?: number;
};
export type GenerationBuild = {
knowledgeId: number;
generation: number;
collection: string;
status: string;
expectedVersions: number;
includedVersions: number;
outstandingJobs: number;
};
export type ActivationGate = {
gateId: number;
knowledgeId: number;
candidateGeneration: number;
status: 'PASSED' | 'BLOCKED';
sampleCount: number;
top1AgreementRate: number;
overlapAt5Avg: number;
governedEmptyRate: number;
evidence: { blockingAssets: number; openCriticalIssues: number; deadLetters: number; generationStatus: string };
};
export type KnowledgeGlossaryTerm = {
id: number;
term: string;
@@ -601,6 +724,111 @@ export function previewLegacyQualityAssets(limit = 50, afterAttachmentId = 0): P
});
}
export function listKnowledgeRolloutScopes(): Promise<ApiResult<KnowledgeRolloutScope[]>> {
return request({
url: '/api/knowledge/quality/rollout/scopes',
method: 'get'
});
}
export function transitionKnowledgeRollout(
knowledgeId: number,
targetMode: KnowledgeRolloutMode,
reason: string,
activeGeneration?: number
): Promise<ApiResult<KnowledgeRolloutScope>> {
return request({
url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/transition`,
method: 'post',
data: { targetMode, reason, activeGeneration },
headers: { repeatSubmit: false }
});
}
export function listAssetVersions(assetId: number): Promise<ApiResult<KnowledgeVersion[]>> {
return request({ url: `/api/knowledge/quality/assets/${assetId}/versions`, method: 'get' });
}
export function getAssetReviewPackage(assetId: number): Promise<ApiResult<ReviewPackage>> {
return request({ url: `/api/knowledge/quality/assets/${assetId}/review-package`, method: 'get' });
}
export function getDeletionImpact(assetId: number): Promise<ApiResult<DeletionImpact>> {
return request({ url: `/api/knowledge/quality/assets/${assetId}/impact`, method: 'get' });
}
export function createPurgeRequest(assetId: number, reason: string): Promise<ApiResult<PurgeRequest>> {
return request({
url: `/api/knowledge/quality/assets/${assetId}/purge-requests`,
method: 'post',
data: { requestKey: `purge:${assetId}:${Date.now()}`, reason },
headers: { repeatSubmit: false }
});
}
export function listPurgeRequests(status = '', limit = 100): Promise<ApiResult<PurgeRequest[]>> {
return request({ url: '/api/knowledge/quality/purge-requests', method: 'get', params: { status, limit } });
}
export function decidePurgeRequest(id: number, decision: 'APPROVE' | 'REJECT', reason: string): Promise<ApiResult<PurgeRequest>> {
return request({ url: `/api/knowledge/quality/purge-requests/${id}/decision`, method: 'post', data: { decision, reason } });
}
export function executePurgeRequest(id: number): Promise<ApiResult<PurgeRequest>> {
return request({ url: `/api/knowledge/quality/purge-requests/${id}/execute`, method: 'post' });
}
export function runKnowledgeReconciliation(): Promise<ApiResult<{ runId: number; status: string; issues: unknown[] }>> {
return request({ url: '/api/knowledge/quality/reconciliation-runs', method: 'post' });
}
export function listMigrationRuns(limit = 50): Promise<ApiResult<MigrationRun[]>> {
return request({ url: '/api/knowledge/quality/migration-runs', method: 'get', params: { limit } });
}
export function createMigrationRun(data: {
runKey: string;
knowledgeId?: number;
riskScope: string;
dryRun: boolean;
batchSize: number;
verifiedDryRunId?: number;
}): Promise<ApiResult<MigrationRun>> {
return request({ url: '/api/knowledge/quality/migration-runs', method: 'post', data, headers: { repeatSubmit: false } });
}
export function runNextMigrationBatch(id: number): Promise<ApiResult<{ moreAvailable: boolean; failed: number }>> {
return request({ url: `/api/knowledge/quality/migration-runs/${id}/next`, method: 'post', headers: { repeatSubmit: false } });
}
export function startGenerationBuild(knowledgeId: number, reason: string): Promise<ApiResult<GenerationBuild>> {
return request({ url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/generation-builds`, method: 'post', data: { reason } });
}
export function refreshGenerationBuild(knowledgeId: number): Promise<ApiResult<GenerationBuild>> {
return request({ url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/generation-builds/refresh`, method: 'post' });
}
export function compareShadowQuery(knowledgeId: number, queryText: string): Promise<ApiResult<unknown>> {
return request({
url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/shadow-comparisons`,
method: 'post',
data: { queryText, source: 'ADMIN_WORKBENCH' }
});
}
export function evaluateActivationGate(knowledgeId: number): Promise<ApiResult<ActivationGate>> {
return request({ url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/activation-gates`, method: 'post' });
}
export function activateGeneration(knowledgeId: number, gateId: number, reason: string): Promise<ApiResult<unknown>> {
return request({ url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/activate`, method: 'post', data: { gateId, reason } });
}
export function rollbackGeneration(knowledgeId: number, reason: string): Promise<ApiResult<unknown>> {
return request({ url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/generation-rollback`, method: 'post', data: { reason } });
}
export function listKnowledgeGlossary(status: KnowledgeGlossaryTerm['status'] = 'ACTIVE', limit = 200): Promise<ApiResult<KnowledgeGlossaryTerm[]>> {
return request({
url: '/api/knowledge/quality/glossary',
+613 -4
View File
@@ -256,6 +256,57 @@
<el-button :icon="Refresh" :loading="qualityLoading" @click="loadQualityAssets">刷新</el-button>
</div>
</div>
<div class="rollout-workbench">
<div class="rollout-head">
<div>
<strong>空间放量</strong>
<span>旧链路、影子验证和治理链路按空间独立切换</span>
</div>
<el-button :icon="Refresh" :loading="rolloutLoading" circle title="刷新空间放量状态" @click="loadRolloutScopes" />
</div>
<el-table v-loading="rolloutLoading" :data="rolloutScopes" size="small" empty-text="暂无可管理知识空间">
<el-table-column prop="spaceName" label="知识空间" min-width="180" show-overflow-tooltip />
<el-table-column label="模式" width="104">
<template #default="{ row }">
<el-tag :type="rolloutModeType(row.mode)" size="small">{{ rolloutModeLabel(row.mode) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="资料覆盖" min-width="170">
<template #default="{ row }">
<span>{{ row.readyAttachments - row.enforcementBlockers }} / {{ row.readyAttachments }}</span>
<el-tag v-if="row.enforcementBlockers" class="rollout-blocker" size="small" type="warning"> 阻断 {{ row.enforcementBlockers }} </el-tag>
</template>
</el-table-column>
<el-table-column label="向量代次" width="100">
<template #default="{ row }">
G{{ row.activeGeneration }}
<el-tag v-if="row.candidateGeneration" size="small" type="warning">候选 G{{ row.candidateGeneration }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="reason" label="最近变更原因" min-width="210" show-overflow-tooltip />
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.mode === 'LEGACY'"
link
type="primary"
:loading="rolloutTransitioningId === row.knowledgeId"
@click="transitionRollout(row, 'SHADOW')"
>进入影子</el-button
>
<template v-else-if="row.mode === 'SHADOW'">
<el-button link :loading="rolloutTransitioningId === row.knowledgeId" @click="transitionRollout(row, 'LEGACY')">退回旧链路</el-button>
<el-button link type="success" :loading="rolloutTransitioningId === row.knowledgeId" @click="openRolloutAssistant(row)"
>验证与启用</el-button
>
</template>
<el-button v-else link type="warning" :loading="rolloutTransitioningId === row.knowledgeId" @click="rollbackActiveGeneration(row)"
>回退上一代</el-button
>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="indexHealth" class="index-health" :class="{ unhealthy: !indexHealth.healthy }">
<div>
<strong>{{ indexHealth.healthy ? '索引一致' : '索引需处理' }}</strong>
@@ -340,6 +391,74 @@
</template>
</el-table-column>
</el-table>
<div class="governance-operations">
<div class="rollout-head">
<div>
<strong>迁移与清理任务</strong>
<span>历史补链默认先演练;物理清理必须经过异人审批和保留期。</span>
</div>
<div class="quality-actions">
<el-button :loading="governanceLoading" @click="createMigrationPlan">新建迁移演练</el-button>
<el-button :loading="governanceLoading" @click="reconcileKnowledgeStores">运行存储对账</el-button>
<el-button :icon="Refresh" circle title="刷新治理任务" @click="loadGovernanceOperations" />
</div>
</div>
<el-tabs>
<el-tab-pane label="历史迁移">
<el-table :data="migrationRuns" size="small" empty-text="暂无迁移任务">
<el-table-column prop="runKey" label="任务" min-width="180" show-overflow-tooltip />
<el-table-column label="模式" width="90"
><template #default="{ row }">{{ row.dryRun ? '演练' : '执行' }}</template></el-table-column
>
<el-table-column prop="status" label="状态" width="150" />
<el-table-column label="进度" min-width="190">
<template #default="{ row }"
>发现 {{ row.discoveredCount }} / 纳管 {{ row.stagedCount }} / 隔离 {{ row.quarantinedCount }} / 失败
{{ row.failedCount }}</template
>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<el-button
v-if="!['COMPLETED', 'COMPLETED_WITH_ERRORS', 'CANCELLED'].includes(row.status)"
link
type="primary"
@click="advanceMigration(row)"
>执行下一批</el-button
>
<el-button
v-if="row.dryRun && row.status === 'COMPLETED' && row.failedCount === 0"
link
type="success"
@click="createFormalMigration(row)"
>生成正式任务</el-button
>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="清理审批">
<el-table :data="purgeRequests" size="small" empty-text="暂无清理申请">
<el-table-column prop="assetId" label="资产" width="90" />
<el-table-column prop="retentionClass" label="保留策略" width="110" />
<el-table-column prop="status" label="状态" width="120" />
<el-table-column prop="reason" label="申请原因" min-width="220" show-overflow-tooltip />
<el-table-column prop="executeAfter" label="最早执行时间" width="170" />
<el-table-column prop="lastError" label="异常" min-width="180" show-overflow-tooltip />
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<template v-if="row.status === 'PENDING'">
<el-button link type="success" @click="reviewPurge(row, 'APPROVE')">批准</el-button>
<el-button link type="danger" @click="reviewPurge(row, 'REJECT')">驳回</el-button>
</template>
<el-button v-if="['APPROVED', 'FAILED'].includes(row.status)" link type="danger" @click="executePurge(row)">执行清理</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</div>
</section>
<section class="panel event-panel">
@@ -781,6 +900,51 @@
<el-descriptions-item label="重试次数">{{ selectedQualityAsset.semanticRetryCount }}</el-descriptions-item>
</el-descriptions>
<section class="asset-governance-summary">
<div class="privacy-preview-head">
<h3>版本与审核辅助</h3>
<el-tag
v-if="reviewPackage"
:type="reviewPackage.riskLevel === 'LOW' ? 'success' : reviewPackage.riskLevel === 'MEDIUM' ? 'warning' : 'danger'"
>
{{ reviewPackage.riskLevel }} 风险
</el-tag>
</div>
<div v-if="reviewPackage" class="review-assistant-grid">
<span>候选 v{{ reviewPackage.versionNo }}</span>
<span>{{ reviewPackage.summary.chunkCount }} 个片段</span>
<span>{{ reviewPackage.summary.duplicateCount }} 组重复</span>
<span>{{ reviewPackage.summary.conflictCount }} 项冲突</span>
</div>
<el-table :data="assetVersions" size="small" max-height="190" empty-text="暂无版本记录">
<el-table-column label="版本" width="86"
><template #default="{ row }">v{{ row.versionNo }}</template></el-table-column
>
<el-table-column prop="versionStatus" label="状态" width="130" />
<el-table-column label="指针" width="150">
<template #default="{ row }">
<el-tag v-if="row.published" size="small" type="success">生效版</el-tag>
<el-tag v-if="row.candidate" size="small" type="warning">候选版</el-tag>
</template>
</el-table-column>
<el-table-column prop="chunkCount" label="片段" width="80" />
<el-table-column prop="createTime" label="创建时间" min-width="170" />
</el-table>
<el-alert
v-if="deletionImpact"
:title="`清理影响:${deletionImpact.publishedFragments} 个生产片段、${deletionImpact.queryEvidence} 条问答证据、${deletionImpact.generations} 个向量代次`"
:type="deletionImpact.canRequestPurge ? 'warning' : 'info'"
:closable="false"
/>
<el-button
v-if="selectedQualityAsset.lifecycleStatus === 'DEPRECATED' && deletionImpact?.canRequestPurge"
type="danger"
plain
@click="requestAssetPurge(selectedQualityAsset)"
>申请物理清理</el-button
>
</section>
<el-alert
v-if="selectedQualityAsset.semanticAnalysisStatus === 'FAILED' || selectedQualityAsset.semanticAnalysisStatus === 'DEAD_LETTER'"
:title="selectedQualityAsset.semanticLastError || '语义分析失败,当前版本不能发布'"
@@ -970,6 +1134,43 @@
</template>
</el-dialog>
<el-dialog v-model="rolloutDialogVisible" title="影子验证与代次启用" width="min(760px, 94vw)" destroy-on-close>
<template v-if="selectedRolloutScope">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="知识空间">{{ selectedRolloutScope.spaceName }}</el-descriptions-item>
<el-descriptions-item label="当前代次">G{{ selectedRolloutScope.activeGeneration }}</el-descriptions-item>
<el-descriptions-item label="候选代次">{{
selectedRolloutScope.candidateGeneration ? `G${selectedRolloutScope.candidateGeneration}` : '尚未构建'
}}</el-descriptions-item>
<el-descriptions-item label="阻断资料">{{ selectedRolloutScope.enforcementBlockers }}</el-descriptions-item>
</el-descriptions>
<div class="rollout-assistant-actions">
<el-button :loading="rolloutAssisting" @click="buildCandidateGeneration">构建候选代次</el-button>
<el-button :loading="rolloutAssisting" @click="refreshCandidateGeneration">刷新构建状态</el-button>
</div>
<el-form label-position="top">
<el-form-item label="影子验证问题">
<el-input v-model="rolloutQuery" maxlength="1000" placeholder="输入一条真实业务问题进行旧链路与治理链路对照" />
</el-form-item>
<el-button :disabled="!rolloutQuery.trim()" :loading="rolloutAssisting" @click="runShadowComparison">记录对照样本</el-button>
<el-button :loading="rolloutAssisting" @click="checkActivationGate">评估启用门禁</el-button>
</el-form>
<el-alert
v-if="rolloutGate"
:title="`门禁 ${rolloutGate.status}:样本 ${rolloutGate.sampleCount},Top1 一致率 ${formatPercent(rolloutGate.top1AgreementRate)},Top5 重合率 ${formatPercent(rolloutGate.overlapAt5Avg)}`"
:type="rolloutGate.status === 'PASSED' ? 'success' : 'warning'"
:closable="false"
show-icon
/>
</template>
<template #footer>
<el-button @click="rolloutDialogVisible = false">关闭</el-button>
<el-button v-if="rolloutGate?.status === 'PASSED'" type="success" :loading="rolloutAssisting" @click="activateCandidateGeneration"
>确认启用候选代次</el-button
>
</template>
</el-dialog>
<el-dialog
v-model="glossaryDialogVisible"
:title="glossaryRevisionSourceId ? '创建术语新修订' : '新建业务术语'"
@@ -1016,15 +1217,24 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
import {
addGoldenSample,
approveKnowledgeGlossary,
activateGeneration,
compareShadowQuery,
createAcceptanceProfile,
createMigrationRun,
createPurgeRequest,
decidePurgeRequest,
executePurgeRequest,
createGoldenDatasetDraft,
createKnowledgeGlossaryDraft,
createProcessingRuleDraft,
deprecateKnowledgeGlossary,
freezeAcceptanceProfile,
freezeGoldenDataset,
getAssetReviewPackage,
getDeletionImpact,
getProcessingRuleMetrics,
getRuleReadiness,
evaluateActivationGate,
getQualityMetrics,
getQualityIndexHealth,
getQualityPrivacyPreview,
@@ -1034,10 +1244,14 @@ import {
listQualityIssues,
listQualityAlerts,
listKnowledgeGlossary,
listAssetVersions,
listAcceptanceProfiles,
listGoldenDatasets,
listGoldenSamples,
listPipelineRuns,
listKnowledgeRolloutScopes,
listMigrationRuns,
listPurgeRequests,
listProcessingRules,
listRuleReviewSamples,
previewLegacyQualityAssets,
@@ -1045,10 +1259,16 @@ import {
rejectKnowledgeGlossary,
reviewRuleSample,
reviewQualityConflict,
rollbackGeneration,
runKnowledgeReconciliation,
runNextMigrationBatch,
retryProcessingTask,
stageLegacyQualityAssets,
scheduleRuleReviewSamples,
transitionProcessingRule,
transitionKnowledgeRollout,
startGenerationBuild,
refreshGenerationBuild,
withdrawQualityAsset,
type ProcessingOverview,
type ProcessingTask,
@@ -1060,7 +1280,15 @@ import {
type QualityIssue,
type QualityPrivacyPreview,
type QualityReviewRequest,
type ReviewPackage,
type KnowledgeVersion,
type DeletionImpact,
type PurgeRequest,
type MigrationRun,
type ActivationGate,
type KnowledgeGlossaryTerm,
type KnowledgeRolloutMode,
type KnowledgeRolloutScope,
type AcceptanceProfile,
type AcceptanceProfileDraft,
type GoldenDataset,
@@ -1078,7 +1306,8 @@ import { uploadKnowledgeDocAsync, listUploadItems, retryUploadItem, type UploadI
import { listKnowledgeSpaces, type KnowledgeSpace } from '@/api/aihr/space';
import { listPersonalPublishRequests, reviewPersonalPublishRequest, type PersonalPublishRequestRow } from '@/api/aihr/personal';
const acceptTypes = '.txt,.md,.markdown,.rtf,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.avi,.mkv,.webm,.m4v,.zip';
const acceptTypes =
'.txt,.md,.markdown,.rtf,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.avi,.mkv,.webm,.m4v,.zip';
const supportedPattern = /\.(txt|md|markdown|rtf|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|webp|bmp|mp4|mov|avi|mkv|webm|m4v|zip)$/i;
const maxFileSize = 500 * 1024 * 1024;
const browserBatchWarnCount = 20;
@@ -1103,6 +1332,20 @@ const indexHealth = ref<QualityIndexHealth | null>(null);
const indexHealthFailed = ref(false);
const qualityMetrics = ref<QualitySnapshot | null>(null);
const qualityAlerts = ref<QualityAlert[]>([]);
const rolloutLoading = ref(false);
const rolloutTransitioningId = ref<number | null>(null);
const rolloutScopes = ref<KnowledgeRolloutScope[]>([]);
const rolloutDialogVisible = ref(false);
const selectedRolloutScope = ref<KnowledgeRolloutScope | null>(null);
const rolloutGate = ref<ActivationGate | null>(null);
const rolloutQuery = ref('');
const rolloutAssisting = ref(false);
const governanceLoading = ref(false);
const migrationRuns = ref<MigrationRun[]>([]);
const purgeRequests = ref<PurgeRequest[]>([]);
const assetVersions = ref<KnowledgeVersion[]>([]);
const reviewPackage = ref<ReviewPackage | null>(null);
const deletionImpact = ref<DeletionImpact | null>(null);
const glossaryLoading = ref(false);
const glossarySaving = ref(false);
const glossaryStatus = ref<KnowledgeGlossaryTerm['status']>('ACTIVE');
@@ -1424,6 +1667,8 @@ onMounted(async () => {
loadQualityAssets(),
loadIndexHealth(),
loadQualityMonitoring(),
loadRolloutScopes(),
loadGovernanceOperations(),
loadGlossary(),
loadRuleWorkbench(),
loadGoldenDatasets()
@@ -1462,6 +1707,284 @@ async function loadQualityAssets() {
}
}
async function loadRolloutScopes() {
rolloutLoading.value = true;
try {
rolloutScopes.value = (await listKnowledgeRolloutScopes()).data || [];
} catch {
rolloutScopes.value = [];
} finally {
rolloutLoading.value = false;
}
}
async function transitionRollout(scope: KnowledgeRolloutScope, targetMode: KnowledgeRolloutMode) {
const targetLabel = rolloutModeLabel(targetMode);
try {
const { value } = await ElMessageBox.prompt(
targetMode === 'ENFORCED'
? '启用后该空间只召回已审核发布且索引就绪的治理版本。'
: targetMode === 'LEGACY'
? '回退后该空间立即继续使用原有资料与旧向量。'
: '影子模式继续服务旧结果,同时允许生成治理链路对照数据。',
`切换到${targetLabel}`,
{
confirmButtonText: '确认切换',
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '填写切换原因',
inputValidator: (input) => Boolean(input?.trim()) || '请填写切换原因'
}
);
rolloutTransitioningId.value = scope.knowledgeId;
await transitionKnowledgeRollout(scope.knowledgeId, targetMode, value.trim(), scope.activeGeneration);
await loadRolloutScopes();
ElMessage.success(`${scope.spaceName}已切换到${targetLabel}`);
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('空间模式切换失败,请检查覆盖率与索引状态');
} finally {
rolloutTransitioningId.value = null;
}
}
async function loadGovernanceOperations() {
governanceLoading.value = true;
try {
const [migrations, purges] = await Promise.all([listMigrationRuns(), listPurgeRequests()]);
migrationRuns.value = migrations.data || [];
purgeRequests.value = purges.data || [];
} catch {
migrationRuns.value = [];
purgeRequests.value = [];
} finally {
governanceLoading.value = false;
}
}
async function createMigrationPlan() {
try {
const { value } = await ElMessageBox.prompt('可选:输入知识空间 ID;留空则覆盖当前租户全部空间。', '新建历史迁移演练', {
confirmButtonText: '创建演练',
cancelButtonText: '取消',
inputPlaceholder: '知识空间 ID(可留空)',
inputValidator: (input) => !input?.trim() || /^\d+$/.test(input.trim()) || '请输入数字 ID'
});
const knowledgeId = value?.trim() ? Number(value.trim()) : undefined;
await createMigrationRun({
runKey: `admin-dry-run:${knowledgeId || 'all'}:${Date.now()}`,
knowledgeId,
riskScope: 'ALL',
dryRun: true,
batchSize: 50
});
await loadGovernanceOperations();
ElMessage.success('迁移演练清单已创建,尚未修改任何资料');
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('迁移演练创建失败');
}
}
async function advanceMigration(run: MigrationRun) {
governanceLoading.value = true;
try {
const result = (await runNextMigrationBatch(run.id)).data;
await loadGovernanceOperations();
ElMessage.success(result.moreAvailable ? '本批完成,可继续下一批' : '迁移任务已完成');
} catch {
ElMessage.error('迁移批次执行失败,任务游标未丢失');
} finally {
governanceLoading.value = false;
}
}
async function createFormalMigration(dryRun: MigrationRun) {
try {
await ElMessageBox.confirm(`将依据演练 #${dryRun.id} 生成同范围正式迁移任务。创建后不会立即改动资料,仍需人工逐批执行。`, '确认生成正式任务', {
confirmButtonText: '生成正式任务',
cancelButtonText: '取消',
type: 'warning'
});
await createMigrationRun({
runKey: `admin-formal:${dryRun.knowledgeId || 'all'}:${Date.now()}`,
knowledgeId: dryRun.knowledgeId,
riskScope: dryRun.riskScope,
dryRun: false,
batchSize: dryRun.batchSize,
verifiedDryRunId: dryRun.id
});
await loadGovernanceOperations();
ElMessage.success('正式迁移任务已创建,尚未执行任何批次');
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('正式迁移任务创建失败');
}
}
async function reconcileKnowledgeStores() {
governanceLoading.value = true;
try {
const result = (await runKnowledgeReconciliation()).data;
ElMessage[result.status === 'PASSED' ? 'success' : 'warning'](
result.status === 'PASSED' ? 'MySQL、OSS 与向量血缘对账通过' : `对账发现 ${result.issues.length} 项待处理问题`
);
} catch {
ElMessage.error('存储对账执行失败');
} finally {
governanceLoading.value = false;
}
}
async function reviewPurge(request: PurgeRequest, decision: 'APPROVE' | 'REJECT') {
try {
const { value } = await ElMessageBox.prompt(
decision === 'APPROVE' ? '批准人必须与申请人不同;批准后仍需等待保留期。' : '请说明驳回依据。',
decision === 'APPROVE' ? '批准清理申请' : '驳回清理申请',
{
confirmButtonText: decision === 'APPROVE' ? '确认批准' : '确认驳回',
cancelButtonText: '取消',
inputType: 'textarea',
inputValidator: (input) => Boolean(input?.trim()) || '请填写审核依据'
}
);
await decidePurgeRequest(request.id, decision, value.trim());
await loadGovernanceOperations();
ElMessage.success(decision === 'APPROVE' ? '清理申请已批准' : '清理申请已驳回');
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('清理申请审核失败');
}
}
async function executePurge(request: PurgeRequest) {
try {
await ElMessageBox.confirm(
`将不可恢复地清理资产 #${request.assetId} 的 MySQL、向量与独占 OSS 数据。系统仍会校验保留期和异人控制。`,
'执行物理清理',
{ confirmButtonText: '确认执行', cancelButtonText: '取消', type: 'error' }
);
await executePurgeRequest(request.id);
await Promise.all([loadGovernanceOperations(), loadQualityAssets(), loadIndexHealth()]);
ElMessage.success('物理清理已完成并保留审计墓碑');
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('物理清理未执行,请检查保留期、审批人和存储状态');
}
}
function openRolloutAssistant(scope: KnowledgeRolloutScope) {
selectedRolloutScope.value = scope;
rolloutGate.value = null;
rolloutQuery.value = '';
rolloutDialogVisible.value = true;
}
async function buildCandidateGeneration() {
const scope = selectedRolloutScope.value;
if (!scope) return;
rolloutAssisting.value = true;
try {
await startGenerationBuild(scope.knowledgeId, '管理端启动候选代次全量构建');
await loadRolloutScopes();
selectedRolloutScope.value = rolloutScopes.value.find((item) => item.knowledgeId === scope.knowledgeId) || scope;
ElMessage.success('候选代次已排队构建');
} catch {
ElMessage.error('候选代次构建启动失败');
} finally {
rolloutAssisting.value = false;
}
}
async function refreshCandidateGeneration() {
const scope = selectedRolloutScope.value;
if (!scope) return;
rolloutAssisting.value = true;
try {
const result = (await refreshGenerationBuild(scope.knowledgeId)).data;
ElMessage.info(`候选 G${result.generation}:${result.status},已纳入 ${result.includedVersions}/${result.expectedVersions}`);
} catch {
ElMessage.error('候选代次状态刷新失败');
} finally {
rolloutAssisting.value = false;
}
}
async function runShadowComparison() {
const scope = selectedRolloutScope.value;
if (!scope || !rolloutQuery.value.trim()) return;
rolloutAssisting.value = true;
try {
await compareShadowQuery(scope.knowledgeId, rolloutQuery.value.trim());
rolloutQuery.value = '';
ElMessage.success('影子对照样本已记录,不影响当前在线结果');
} catch {
ElMessage.error('影子对照失败,请确认候选代次已创建');
} finally {
rolloutAssisting.value = false;
}
}
async function checkActivationGate() {
const scope = selectedRolloutScope.value;
if (!scope) return;
rolloutAssisting.value = true;
try {
rolloutGate.value = (await evaluateActivationGate(scope.knowledgeId)).data;
} catch {
ElMessage.error('启用门禁评估失败');
} finally {
rolloutAssisting.value = false;
}
}
async function activateCandidateGeneration() {
const scope = selectedRolloutScope.value;
const gate = rolloutGate.value;
if (!scope || !gate || gate.status !== 'PASSED') return;
try {
const { value } = await ElMessageBox.prompt('启用后员工检索将切换到候选代次,七天内可回退。', '确认启用治理链路', {
confirmButtonText: '确认启用',
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '填写启用原因',
inputValidator: (input) => Boolean(input?.trim()) || '请填写启用原因'
});
rolloutAssisting.value = true;
await activateGeneration(scope.knowledgeId, gate.gateId, value.trim());
rolloutDialogVisible.value = false;
await loadRolloutScopes();
ElMessage.success('候选代次已启用,回退窗口为七天');
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('候选代次启用失败');
} finally {
rolloutAssisting.value = false;
}
}
async function rollbackActiveGeneration(scope: KnowledgeRolloutScope) {
try {
const { value } = await ElMessageBox.prompt('仅能在最近一次启用后的七天窗口内回退。', '回退向量代次', {
confirmButtonText: '确认回退',
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '填写回退原因',
inputValidator: (input) => Boolean(input?.trim()) || '请填写回退原因'
});
rolloutTransitioningId.value = scope.knowledgeId;
await rollbackGeneration(scope.knowledgeId, value.trim());
await loadRolloutScopes();
ElMessage.success('已回退上一代并返回影子模式');
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('代次回退失败或已超过回退窗口');
} finally {
rolloutTransitioningId.value = null;
}
}
function rolloutModeLabel(mode: KnowledgeRolloutMode) {
return { LEGACY: '旧链路', SHADOW: '影子', ENFORCED: '治理链路' }[mode];
}
function rolloutModeType(mode: KnowledgeRolloutMode) {
return ({ LEGACY: 'info', SHADOW: 'warning', ENFORCED: 'success' } as const)[mode];
}
async function loadGlossary() {
glossaryLoading.value = true;
try {
@@ -2059,6 +2582,9 @@ async function openQualityReview(asset: QualityAsset) {
qualityPrivacyPreview.value = null;
qualityConflicts.value = [];
publishedQualityAssets.value = [];
assetVersions.value = [];
reviewPackage.value = null;
deletionImpact.value = null;
qualityDetailLoading.value = true;
qualityDialogVisible.value = true;
qualityReviewForm.value = {
@@ -2075,23 +2601,32 @@ async function openQualityReview(asset: QualityAsset) {
supersedesAssetId: undefined
};
try {
const [issues, privacyPreview, conflicts, published] = await Promise.all([
const [issues, privacyPreview, conflicts, published, versions, impact, review] = await Promise.all([
listQualityIssues(asset.id),
getQualityPrivacyPreview(asset.id),
listQualityConflicts(asset.id),
listQualityAssets('PUBLISHED', 200)
listQualityAssets('PUBLISHED', 200),
listAssetVersions(asset.id),
getDeletionImpact(asset.id),
asset.lifecycleStatus === 'REVIEW_PENDING' ? getAssetReviewPackage(asset.id) : Promise.resolve({ data: null })
]);
if (selectedQualityAsset.value?.id !== asset.id) return;
qualityIssues.value = issues.data || [];
qualityPrivacyPreview.value = privacyPreview.data || null;
qualityConflicts.value = conflicts.data || [];
publishedQualityAssets.value = (published.data || []).filter((item) => item.id !== asset.id);
assetVersions.value = versions.data || [];
deletionImpact.value = impact.data || null;
reviewPackage.value = review.data || null;
} catch {
if (selectedQualityAsset.value?.id !== asset.id) return;
qualityIssues.value = [];
qualityPrivacyPreview.value = null;
qualityConflicts.value = [];
publishedQualityAssets.value = [];
assetVersions.value = [];
reviewPackage.value = null;
deletionImpact.value = null;
ElMessage.error('质量原因加载失败');
} finally {
if (selectedQualityAsset.value?.id === asset.id) qualityDetailLoading.value = false;
@@ -2578,6 +3113,23 @@ function fileBadgeClass(type: string) {
if (type === '视频') return 'ppt';
return 'text';
}
async function requestAssetPurge(asset: QualityAsset) {
try {
const { value } = await ElMessageBox.prompt('申请不会立即删除。系统将在保留期结束后,要求另一名管理员审批并执行。', '申请物理清理', {
confirmButtonText: '提交申请',
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '填写必须物理清理的原因',
inputValidator: (input) => Boolean(input?.trim()) || '请填写清理原因'
});
await createPurgeRequest(asset.id, value.trim());
qualityDialogVisible.value = false;
await loadGovernanceOperations();
ElMessage.success('清理申请已提交,等待异人审批和保留期');
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('清理申请提交失败');
}
}
</script>
<style scoped lang="scss">
@@ -3082,6 +3634,34 @@ function fileBadgeClass(type: string) {
}
}
.rollout-workbench {
margin-bottom: 16px;
border-top: 1px solid #e5e7eb;
}
.rollout-head {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 52px;
gap: 16px;
}
.rollout-head > div {
display: flex;
align-items: baseline;
gap: 12px;
}
.rollout-head span {
color: #6b7280;
font-size: 13px;
}
.rollout-blocker {
margin-left: 8px;
}
.index-health.unhealthy {
border-left-color: #d64545;
background: #fff4f4;
@@ -3225,6 +3805,34 @@ function fileBadgeClass(type: string) {
}
}
.governance-operations,
.asset-governance-summary {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid #e4e7ed;
}
.review-assistant-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-bottom: 12px;
span {
padding: 8px 10px;
border: 1px solid #e4e7ed;
background: #f8fafc;
font-size: 13px;
}
}
.rollout-assistant-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 16px 0;
}
.quality-issue-list {
margin: 18px 0;
@@ -3421,7 +4029,8 @@ function fileBadgeClass(type: string) {
.quality-form-grid,
.reason-checks,
.conflict-sources {
.conflict-sources,
.review-assistant-grid {
grid-template-columns: 1fr;
}
+84 -1
View File
@@ -269,6 +269,24 @@ FROM (
UNION ALL SELECT 'aihr_personal_ocr_page'
UNION ALL SELECT 'aihr_personal_export_task'
UNION ALL SELECT 'aihr_personal_publish_request'
UNION ALL SELECT 'aihr_knowledge_rollout_scope'
UNION ALL SELECT 'aihr_knowledge_rollout_transition'
UNION ALL SELECT 'aihr_review_assistance'
UNION ALL SELECT 'aihr_review_batch'
UNION ALL SELECT 'aihr_review_batch_item'
UNION ALL SELECT 'aihr_knowledge_generation'
UNION ALL SELECT 'aihr_generation_version'
UNION ALL SELECT 'aihr_version_diff'
UNION ALL SELECT 'aihr_version_rollback'
UNION ALL SELECT 'aihr_deletion_request'
UNION ALL SELECT 'aihr_reconciliation_run'
UNION ALL SELECT 'aihr_reconciliation_issue'
UNION ALL SELECT 'aihr_migration_run'
UNION ALL SELECT 'aihr_migration_batch'
UNION ALL SELECT 'aihr_migration_dead_letter'
UNION ALL SELECT 'aihr_shadow_comparison'
UNION ALL SELECT 'aihr_activation_gate'
UNION ALL SELECT 'aihr_generation_activation'
) required
LEFT JOIN information_schema.tables actual
ON actual.table_schema = DATABASE() AND actual.table_name = required.table_name
@@ -838,6 +856,69 @@ REMOTE
[[ -z "$missing_work_assistant_columns" ]] \
|| fail "remote work-assistant schema missing required columns: $(printf '%s' "$missing_work_assistant_columns" | tr '\n' ', ' | sed 's/, $//')"
local missing_knowledge_lifecycle_columns
missing_knowledge_lifecycle_columns="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
set -euo pipefail
db="$1"
mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL'
SELECT CONCAT(required.table_name, '.', required.column_name)
FROM (
SELECT 'aihr_data_asset' AS table_name, 'published_version_id' AS column_name
UNION ALL SELECT 'aihr_data_asset', 'candidate_version_id'
UNION ALL SELECT 'aihr_data_asset', 'published_time'
UNION ALL SELECT 'aihr_data_asset', 'retention_class'
UNION ALL SELECT 'aihr_data_asset', 'legal_hold'
UNION ALL SELECT 'aihr_data_asset', 'delete_state'
UNION ALL SELECT 'aihr_data_version', 'version_status'
UNION ALL SELECT 'aihr_data_version', 'revision_of_version_id'
UNION ALL SELECT 'aihr_data_version', 'revision_reason'
UNION ALL SELECT 'aihr_chunk_revision', 'index_generation'
UNION ALL SELECT 'aihr_chunk_revision', 'vector_point_id'
UNION ALL SELECT 'aihr_index_outbox', 'index_generation'
UNION ALL SELECT 'aihr_migration_run', 'verified_dry_run_id'
) required
LEFT JOIN information_schema.columns actual
ON actual.table_schema = DATABASE()
AND actual.table_name = required.table_name
AND actual.column_name = required.column_name
WHERE actual.column_name IS NULL
ORDER BY required.table_name, required.column_name;
SQL
REMOTE
)" || fail "remote knowledge-lifecycle schema check failed: $remote_ssh:$remote_db"
[[ -z "$missing_knowledge_lifecycle_columns" ]] \
|| fail "remote knowledge-lifecycle schema missing required columns: $(printf '%s' "$missing_knowledge_lifecycle_columns" | tr '\n' ', ' | sed 's/, $//')"
local knowledge_lifecycle_index_contract
knowledge_lifecycle_index_contract="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
set -euo pipefail
db="$1"
mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL'
SELECT CONCAT(
COALESCE((SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index)
FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'aihr_data_asset' AND index_name = 'idx_aihr_data_asset_published_version'), 'missing'), '|',
COALESCE((SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index)
FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'aihr_data_asset' AND index_name = 'idx_aihr_data_asset_candidate_version'), 'missing'), '|',
COALESCE((SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index)
FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'aihr_data_version' AND index_name = 'idx_aihr_data_version_status'), 'missing'), '|',
COALESCE((SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index)
FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'aihr_chunk_revision' AND index_name = 'idx_aihr_chunk_revision_generation'), 'missing'), '|',
COALESCE((SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index)
FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'aihr_migration_run' AND index_name = 'idx_aihr_migration_verified_dry_run'), 'missing')
);
SQL
REMOTE
)" || fail "remote knowledge-lifecycle index check failed: $remote_ssh:$remote_db"
[[ "$knowledge_lifecycle_index_contract" = "tenant_id,published_version_id|tenant_id,candidate_version_id|tenant_id,version_status,id|tenant_id,asset_id,version_id,index_generation|tenant_id,verified_dry_run_id" ]] \
|| fail "remote knowledge-lifecycle index contract is invalid: got ${knowledge_lifecycle_index_contract:-missing}"
local media_reprocess_contract
media_reprocess_contract="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
set -euo pipefail
@@ -865,7 +946,7 @@ REMOTE
[[ "$media_reprocess_contract" = "source_attach_id|tenant_id,source_attach_id,id" ]] \
|| fail "remote media-reprocess schema is invalid: got ${media_reprocess_contract:-missing}"
echo "remote_schema=64/64 $remote_ssh:$remote_db"
echo "remote_schema=82/82 $remote_ssh:$remote_db"
echo "remote_media_reprocess_contract=1/1 $remote_ssh:$remote_db"
echo "remote_work_report_idempotency=3/3 $remote_ssh:$remote_db"
echo "remote_learning_task_question_bank=6/6 $remote_ssh:$remote_db"
@@ -884,6 +965,8 @@ REMOTE
echo "remote_broadcast_attachment_contract=15/15 $remote_ssh:$remote_db"
echo "remote_broadcast_question_context=1/1 $remote_ssh:$remote_db"
echo "remote_work_assistant_columns=8/8 $remote_ssh:$remote_db"
echo "remote_knowledge_lifecycle_columns=13/13 $remote_ssh:$remote_db"
echo "remote_knowledge_lifecycle_indexes=5/5 $remote_ssh:$remote_db"
echo "remote_personal_oss_configuration=true $remote_ssh:$remote_db"
}
+7
View File
@@ -36,6 +36,13 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260812_pipeline_run_sampling_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260813_golden_calibration_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260814_knowledge_privacy_derivative_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260815_knowledge_rollout_compat_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260816_knowledge_version_pointers_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260817_knowledge_review_assistance_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260818_knowledge_generation_revision_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260819_knowledge_retention_deletion_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260820_knowledge_migration_orchestration_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260821_knowledge_shadow_gate_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_personal_knowledge_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_model_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260725_deepseek_v4_model_mysql8.sql"
@@ -44,6 +44,13 @@ grep -Fq 'artifact_commit="${RELEASE_ARTIFACT_COMMIT:-HEAD}"' "$SCRIPT"
grep -Fq 'git merge-base --is-ancestor "$artifact_commit_sha" HEAD' "$SCRIPT"
grep -Fq 'remote_media_reprocess_contract=1/1' "$SCRIPT"
grep -Fq "source_attach_id|tenant_id,source_attach_id,id" "$SCRIPT"
grep -Fq "UNION ALL SELECT 'aihr_knowledge_rollout_scope'" "$SCRIPT"
grep -Fq "UNION ALL SELECT 'aihr_generation_activation'" "$SCRIPT"
grep -Fq "UNION ALL SELECT 'aihr_data_asset', 'delete_state'" "$SCRIPT"
grep -Fq "UNION ALL SELECT 'aihr_migration_run', 'verified_dry_run_id'" "$SCRIPT"
grep -Fq 'remote_knowledge_lifecycle_columns=13/13' "$SCRIPT"
grep -Fq 'remote_knowledge_lifecycle_indexes=5/5' "$SCRIPT"
grep -Fq 'tenant_id,published_version_id|tenant_id,candidate_version_id|tenant_id,version_status,id|tenant_id,asset_id,version_id,index_generation|tenant_id,verified_dry_run_id' "$SCRIPT"
grep -Fq 'RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true ./scripts/release-preflight.sh' "$DEV_SETUP"
grep -Fq 'RELEASE_VERIFY_AUG1_READINESS=true ./scripts/release-preflight.sh' "$DEV_SETUP"
grep -Fq 'RELEASE_VERIFY_REMOTE_MATCH=true RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true RELEASE_VERIFY_AUG1_READINESS=true ./scripts/release-preflight.sh' "$DEV_SETUP"