feat: complete governed knowledge lifecycle
This commit is contained in:
+76
-15
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+49
-11
@@ -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;
|
||||
|
||||
+273
-67
@@ -42,8 +42,8 @@ public class AihrKnowledgeLifecycleService {
|
||||
private final AihrKnowledgeQualityGateService qualityGateService;
|
||||
private final AihrKnowledgeClaimService claimService;
|
||||
|
||||
@Value("${aihr.qdrant.collection:${AIHR_QDRANT_COLLECTION:aihr_knowledge}}")
|
||||
private String productionCollection = "aihr_knowledge";
|
||||
@Value("${aihr.qdrant.governed-collection:${AIHR_QDRANT_GOVERNED_COLLECTION:aihr_knowledge_governed_v1}}")
|
||||
private String productionCollection = AihrKnowledgeRolloutService.DEFAULT_GOVERNED_COLLECTION;
|
||||
|
||||
@Transactional
|
||||
public StagedAsset stageParsedDocument(StageCommand command) {
|
||||
@@ -154,12 +154,22 @@ public class AihrKnowledgeLifecycleService {
|
||||
semantic_detector_version = ? where tenant_id = ? and id = ?
|
||||
""", AihrKnowledgeSemanticAnalysisService.DETECTOR_VERSION, command.tenantId(), versionId);
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
update aihr_data_version
|
||||
set version_status = ?
|
||||
where tenant_id = ? and id = ? and immutable_status = 'SEALED'
|
||||
""", assessment.status().name(), command.tenantId(), versionId);
|
||||
jdbcTemplate.update("""
|
||||
update aihr_data_asset
|
||||
set current_version_id = ?, lifecycle_status = ?, index_status = 'NOT_REQUIRED',
|
||||
trust_level = 'UNTRUSTED', content_sha256 = ?, update_time = now()
|
||||
set candidate_version_id = ?,
|
||||
current_version_id = case when published_version_id is null then ? else current_version_id end,
|
||||
lifecycle_status = case when published_version_id is null then ? else lifecycle_status end,
|
||||
index_status = case when published_version_id is null then 'NOT_REQUIRED' else index_status end,
|
||||
trust_level = case when published_version_id is null then 'UNTRUSTED' else trust_level end,
|
||||
content_sha256 = case when published_version_id is null then ? else content_sha256 end,
|
||||
update_time = now()
|
||||
where tenant_id = ? and id = ?
|
||||
""", versionId, assessment.status().name(), contentHash, command.tenantId(), assetId);
|
||||
""", versionId, versionId, assessment.status().name(), contentHash, command.tenantId(), assetId);
|
||||
List<String> reasonCodes = new ArrayList<>(assessment.findings().stream()
|
||||
.map(finding -> finding.reasonCode().name()).toList());
|
||||
dynamicReasons.stream().map(Enum::name).forEach(reasonCodes::add);
|
||||
@@ -191,12 +201,22 @@ public class AihrKnowledgeLifecycleService {
|
||||
String.valueOf(versionId), "PARSE_FAILED", command.extractionQuality().extractorName(),
|
||||
command.extractionQuality().extractorVersion());
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
update aihr_data_version
|
||||
set version_status = 'QUARANTINED'
|
||||
where tenant_id = ? and id = ? and immutable_status = 'SEALED'
|
||||
""", command.tenantId(), versionId);
|
||||
jdbcTemplate.update("""
|
||||
update aihr_data_asset
|
||||
set current_version_id = ?, lifecycle_status = 'QUARANTINED', index_status = 'NOT_REQUIRED',
|
||||
trust_level = 'UNTRUSTED', content_sha256 = ?, update_time = now()
|
||||
set candidate_version_id = ?,
|
||||
current_version_id = case when published_version_id is null then ? else current_version_id end,
|
||||
lifecycle_status = case when published_version_id is null then 'QUARANTINED' else lifecycle_status end,
|
||||
index_status = case when published_version_id is null then 'NOT_REQUIRED' else index_status end,
|
||||
trust_level = case when published_version_id is null then 'UNTRUSTED' else trust_level end,
|
||||
content_sha256 = case when published_version_id is null then ? else content_sha256 end,
|
||||
update_time = now()
|
||||
where tenant_id = ? and id = ?
|
||||
""", versionId, rawHash, command.tenantId(), assetId);
|
||||
""", versionId, versionId, rawHash, command.tenantId(), assetId);
|
||||
return new StagedAsset(assetId, versionId, versionNo, Status.QUARANTINED.name(),
|
||||
List.of(command.reasonCode().name()), 0);
|
||||
}
|
||||
@@ -212,7 +232,8 @@ public class AihrKnowledgeLifecycleService {
|
||||
a.source_authority, a.source_version, a.data_class, a.lifecycle_status, a.index_status,
|
||||
a.applicable_region, a.applicable_project, a.applicable_role,
|
||||
a.effective_from, a.effective_to,
|
||||
a.current_version_id, v.semantic_analysis_status, v.semantic_last_error,
|
||||
coalesce(a.candidate_version_id, a.current_version_id) as current_version_id,
|
||||
v.semantic_analysis_status, v.semantic_last_error, v.version_status,
|
||||
v.semantic_retry_count, a.update_time,
|
||||
(select count(*) from aihr_quality_issue q
|
||||
where q.tenant_id = a.tenant_id and q.asset_id = a.id and q.status = 'OPEN') issue_count,
|
||||
@@ -220,8 +241,11 @@ public class AihrKnowledgeLifecycleService {
|
||||
where q.tenant_id = a.tenant_id and q.asset_id = a.id and q.status = 'OPEN'
|
||||
and q.gate_type = 'HARD') blocking_issue_count
|
||||
from aihr_data_asset a
|
||||
join aihr_data_version v on v.tenant_id = a.tenant_id and v.id = a.current_version_id
|
||||
where a.tenant_id = ? and a.lifecycle_status = ?
|
||||
join aihr_data_version v on v.tenant_id = a.tenant_id
|
||||
and v.id = coalesce(a.candidate_version_id, a.current_version_id)
|
||||
where a.tenant_id = ?
|
||||
and ((? = 'PUBLISHED' and a.lifecycle_status = 'PUBLISHED')
|
||||
or (? <> 'PUBLISHED' and v.version_status = ?))
|
||||
order by a.update_time asc, a.id asc
|
||||
limit ?
|
||||
""", (rs, rowNum) -> new AssetSummary(
|
||||
@@ -235,7 +259,7 @@ public class AihrKnowledgeLifecycleService {
|
||||
rs.getObject("current_version_id", Long.class), rs.getString("semantic_analysis_status"),
|
||||
rs.getString("semantic_last_error"), rs.getInt("semantic_retry_count"), rs.getInt("issue_count"),
|
||||
rs.getInt("blocking_issue_count"), rs.getObject("update_time", LocalDateTime.class)),
|
||||
tenantId, normalizedStatus, boundedLimit);
|
||||
tenantId, normalizedStatus, normalizedStatus, normalizedStatus, boundedLimit);
|
||||
}
|
||||
|
||||
public List<QualityIssue> issues(String tenantId, long assetId) {
|
||||
@@ -256,7 +280,8 @@ public class AihrKnowledgeLifecycleService {
|
||||
select v.id, v.privacy_status, v.redaction_policy_version, v.redaction_summary_json,
|
||||
v.redacted_source_name, v.redacted_content
|
||||
from aihr_data_asset a
|
||||
join aihr_data_version v on v.tenant_id = a.tenant_id and v.id = a.current_version_id
|
||||
join aihr_data_version v on v.tenant_id = a.tenant_id
|
||||
and v.id = coalesce(a.candidate_version_id, a.current_version_id)
|
||||
where a.tenant_id = ? and a.id = ?
|
||||
limit 1
|
||||
""", (rs, rowNum) -> new PrivacyPreview(
|
||||
@@ -276,15 +301,22 @@ public class AihrKnowledgeLifecycleService {
|
||||
}
|
||||
AssetRow asset = lockAsset(tenantId, assetId);
|
||||
Status current = Status.valueOf(asset.lifecycleStatus());
|
||||
if (!AihrKnowledgeLifecycle.canApprove(current)) {
|
||||
boolean candidateReview = asset.candidateVersionId() != null
|
||||
&& versionStatus(tenantId, asset.versionId()).map(status ->
|
||||
Set.of("REVIEW_PENDING", "QUARANTINED", "APPROVED").contains(status)).orElse(false);
|
||||
if (!AihrKnowledgeLifecycle.canApprove(current) && !candidateReview) {
|
||||
throw new ServiceException("Asset is not awaiting review", HttpStatus.CONFLICT);
|
||||
}
|
||||
long versionId = asset.versionId();
|
||||
if (command.expectedVersionId() != null && command.expectedVersionId() != versionId) {
|
||||
throw new ServiceException("Candidate version changed; reload the review package", HttpStatus.CONFLICT);
|
||||
}
|
||||
VersionGate versionGate = jdbcTemplate.queryForObject("""
|
||||
select semantic_analysis_status, privacy_status,
|
||||
case when redacted_content is null or redaction_policy_version is null then 0 else 1 end privacy_ready
|
||||
from aihr_data_version where tenant_id = ? and id = ?
|
||||
""", (rs, rowNum) -> new VersionGate(rs.getString("semantic_analysis_status"),
|
||||
rs.getString("privacy_status"), rs.getBoolean("privacy_ready")), tenantId, asset.versionId());
|
||||
rs.getString("privacy_status"), rs.getBoolean("privacy_ready")), tenantId, versionId);
|
||||
if (versionGate == null || !versionGate.privacyReady()
|
||||
|| !Set.of("CLEAN", "REDACTED").contains(versionGate.privacyStatus())) {
|
||||
throw new ServiceException("A current privacy derivative and residual scan are required before approval",
|
||||
@@ -294,19 +326,19 @@ public class AihrKnowledgeLifecycleService {
|
||||
throw new ServiceException("Semantic duplicate analysis must complete before approval",
|
||||
HttpStatus.CONFLICT);
|
||||
}
|
||||
claimService.requireResolvedConflicts(tenantId, assetId, asset.versionId());
|
||||
acceptSoftFindings(tenantId, assetId, asset.versionId(), reviewerId, command.acceptedReasonCodes());
|
||||
claimService.requireResolvedConflicts(tenantId, assetId, versionId);
|
||||
acceptSoftFindings(tenantId, assetId, versionId, reviewerId, command.acceptedReasonCodes());
|
||||
Integer hardOpen = jdbcTemplate.queryForObject("""
|
||||
select count(*) from aihr_quality_issue
|
||||
where tenant_id = ? and asset_id = ? and version_id = ? and status = 'OPEN' and gate_type = 'HARD'
|
||||
""", Integer.class, tenantId, assetId, asset.versionId());
|
||||
""", Integer.class, tenantId, assetId, versionId);
|
||||
if (hardOpen != null && hardOpen > 0) {
|
||||
throw new ServiceException("Blocking quality findings must be resolved before approval", HttpStatus.CONFLICT);
|
||||
}
|
||||
Integer softOpen = jdbcTemplate.queryForObject("""
|
||||
select count(*) from aihr_quality_issue
|
||||
where tenant_id = ? and asset_id = ? and version_id = ? and status = 'OPEN' and gate_type = 'SOFT'
|
||||
""", Integer.class, tenantId, assetId, asset.versionId());
|
||||
""", Integer.class, tenantId, assetId, versionId);
|
||||
if (softOpen != null && softOpen > 0) {
|
||||
throw new ServiceException("Every soft quality finding must be acknowledged before approval", HttpStatus.CONFLICT);
|
||||
}
|
||||
@@ -325,7 +357,7 @@ public class AihrKnowledgeLifecycleService {
|
||||
"Normative knowledge requires an approved authority type and source version", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (usage == UsageType.NORMATIVE_KNOWLEDGE
|
||||
&& hasQualityReason(tenantId, assetId, asset.versionId(), ReasonCode.VERSION_CONFLICT)
|
||||
&& hasQualityReason(tenantId, assetId, versionId, ReasonCode.VERSION_CONFLICT)
|
||||
&& command.supersedesAssetId() == null) {
|
||||
throw new ServiceException("A conflicting normative source must explicitly supersede the old asset",
|
||||
HttpStatus.CONFLICT);
|
||||
@@ -344,13 +376,12 @@ public class AihrKnowledgeLifecycleService {
|
||||
"Superseded by asset " + assetId + ": " + command.reason().trim());
|
||||
}
|
||||
|
||||
removePublishedFragments(tenantId, assetId);
|
||||
List<ChunkRow> chunks = jdbcTemplate.query("""
|
||||
select id, chunk_index, content, locator_json from aihr_chunk_revision
|
||||
where tenant_id = ? and asset_id = ? and version_id = ? order by chunk_index
|
||||
""", (rs, rowNum) -> new ChunkRow(rs.getLong("id"), rs.getInt("chunk_index"), rs.getString("content"),
|
||||
rs.getString("locator_json")),
|
||||
tenantId, assetId, asset.versionId());
|
||||
tenantId, assetId, versionId);
|
||||
if (chunks.isEmpty()) {
|
||||
throw new ServiceException("Approved version has no chunks", HttpStatus.CONFLICT);
|
||||
}
|
||||
@@ -374,7 +405,7 @@ public class AihrKnowledgeLifecycleService {
|
||||
""", Statement.RETURN_GENERATED_KEYS);
|
||||
statement.setString(1, tenantId);
|
||||
statement.setLong(2, assetId);
|
||||
statement.setLong(3, asset.versionId());
|
||||
statement.setLong(3, versionId);
|
||||
statement.setString(4, command.reason().trim());
|
||||
statement.setString(5, json(new Snapshot(asset.lifecycleStatus(), asset.dataClass(), asset.sourceVersion())));
|
||||
statement.setString(6, json(new Snapshot("PUBLISHED", usage.name(), command.sourceVersion())));
|
||||
@@ -388,15 +419,31 @@ public class AihrKnowledgeLifecycleService {
|
||||
values (?, ?, 'production', ?, 'ACTIVE', ?, now())
|
||||
on duplicate key update usage_type = values(usage_type), status = 'ACTIVE',
|
||||
approved_review_id = values(approved_review_id)
|
||||
""", tenantId, asset.versionId(), usage.name(), reviewId);
|
||||
enqueueIndex(tenantId, assetId, asset.versionId(), "UPSERT", asset.docId());
|
||||
""", tenantId, versionId, usage.name(), reviewId);
|
||||
if (asset.publishedVersionId() != null && asset.publishedVersionId() != versionId) {
|
||||
jdbcTemplate.update("""
|
||||
update aihr_dataset_membership set status = 'DISABLED'
|
||||
where tenant_id = ? and version_id = ? and dataset_code = 'production'
|
||||
""", tenantId, asset.publishedVersionId());
|
||||
jdbcTemplate.update("""
|
||||
update aihr_data_version set version_status = 'SUPERSEDED'
|
||||
where tenant_id = ? and id = ? and version_status = 'PUBLISHED'
|
||||
""", tenantId, asset.publishedVersionId());
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
update aihr_data_version set version_status = 'PUBLISHED'
|
||||
where tenant_id = ? and id = ? and immutable_status = 'SEALED'
|
||||
""", tenantId, versionId);
|
||||
enqueueIndex(tenantId, assetId, versionId, "UPSERT", asset.docId());
|
||||
jdbcTemplate.update("""
|
||||
update aihr_data_asset
|
||||
set lifecycle_status = 'PUBLISHED', index_status = 'PENDING', data_class = ?, update_time = now()
|
||||
set lifecycle_status = 'PUBLISHED', index_status = 'PENDING', data_class = ?,
|
||||
published_version_id = ?, candidate_version_id = null, current_version_id = ?,
|
||||
published_time = coalesce(published_time, now()), update_time = now()
|
||||
where tenant_id = ? and id = ?
|
||||
""", usage.name(), tenantId, assetId);
|
||||
claimService.markPublished(tenantId, asset.versionId());
|
||||
return new PublishedAsset(assetId, asset.versionId(), "PUBLISHED", "PENDING", chunks.size(), reviewId);
|
||||
""", usage.name(), versionId, versionId, tenantId, assetId);
|
||||
claimService.markPublished(tenantId, versionId);
|
||||
return new PublishedAsset(assetId, versionId, "PUBLISHED", "PENDING", chunks.size(), reviewId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -408,13 +455,28 @@ public class AihrKnowledgeLifecycleService {
|
||||
if (!AihrKnowledgeLifecycle.canWithdraw(Status.valueOf(asset.lifecycleStatus()))) {
|
||||
throw new ServiceException("Only approved or published assets can be withdrawn", HttpStatus.CONFLICT);
|
||||
}
|
||||
int fragments = removePublishedFragments(tenantId, assetId);
|
||||
long versionId = asset.publishedVersionId() == null ? asset.versionId() : asset.publishedVersionId();
|
||||
Integer fragments = jdbcTemplate.queryForObject("""
|
||||
select count(*) from aihr_chunk_revision
|
||||
where tenant_id = ? and asset_id = ? and version_id = ? and published_fragment_id is not null
|
||||
""", Integer.class, tenantId, assetId, versionId);
|
||||
jdbcTemplate.update("update aihr_dataset_membership set status = 'DISABLED' where tenant_id = ? and version_id = ?",
|
||||
tenantId, asset.versionId());
|
||||
enqueueIndex(tenantId, assetId, asset.versionId(), "DELETE", asset.docId());
|
||||
tenantId, versionId);
|
||||
jdbcTemplate.update("""
|
||||
update aihr_data_version set version_status = 'WITHDRAWN'
|
||||
where tenant_id = ? and id = ? and version_status in ('PUBLISHED','APPROVED','REVIEW_PENDING','QUARANTINED')
|
||||
""", tenantId, versionId);
|
||||
if (asset.candidateVersionId() != null && asset.candidateVersionId() != versionId) {
|
||||
jdbcTemplate.update("""
|
||||
update aihr_data_version set version_status = 'WITHDRAWN'
|
||||
where tenant_id = ? and id = ? and version_status in ('DRAFT','PROCESSING','REVIEW_PENDING','QUARANTINED','APPROVED')
|
||||
""", tenantId, asset.candidateVersionId());
|
||||
}
|
||||
enqueueIndex(tenantId, assetId, versionId, "DELETE", asset.docId());
|
||||
jdbcTemplate.update("""
|
||||
update aihr_data_asset
|
||||
set lifecycle_status = 'DEPRECATED', index_status = 'PENDING', trust_level = 'WITHDRAWN', update_time = now()
|
||||
set lifecycle_status = 'DEPRECATED', index_status = 'PENDING', trust_level = 'WITHDRAWN',
|
||||
published_version_id = null, candidate_version_id = null, update_time = now()
|
||||
where tenant_id = ? and id = ?
|
||||
""", tenantId, assetId);
|
||||
claimService.markDeprecated(tenantId, assetId);
|
||||
@@ -422,8 +484,8 @@ public class AihrKnowledgeLifecycleService {
|
||||
insert into aihr_review_decision
|
||||
(tenant_id, asset_id, version_id, decision, reason, reviewer_id, reviewer_type, reviewed_time)
|
||||
values (?, ?, ?, 'WITHDRAW', ?, ?, 'HUMAN', now())
|
||||
""", tenantId, assetId, asset.versionId(), reason.trim(), reviewerId);
|
||||
return new PublishedAsset(assetId, asset.versionId(), "DEPRECATED", "PENDING", fragments, null);
|
||||
""", tenantId, assetId, versionId, reason.trim(), reviewerId);
|
||||
return new PublishedAsset(assetId, versionId, "DEPRECATED", "PENDING", fragments == null ? 0 : fragments, null);
|
||||
}
|
||||
|
||||
private long upsertAsset(StageCommand command, String contentHash) {
|
||||
@@ -650,7 +712,9 @@ public class AihrKnowledgeLifecycleService {
|
||||
List<DuplicateMatch> rows = jdbcTemplate.query("""
|
||||
select a.id asset_id, v.id version_id
|
||||
from aihr_data_asset a
|
||||
join aihr_data_version v on v.tenant_id = a.tenant_id and v.id = a.current_version_id
|
||||
join aihr_data_version v on v.tenant_id = a.tenant_id
|
||||
and v.id = coalesce(a.candidate_version_id,
|
||||
a.published_version_id, a.current_version_id)
|
||||
where a.tenant_id = ? and a.id <> ? and v.content_sha256 = ?
|
||||
order by case a.lifecycle_status when 'PUBLISHED' then 0 else 1 end, a.update_time desc
|
||||
limit 1
|
||||
@@ -711,7 +775,9 @@ public class AihrKnowledgeLifecycleService {
|
||||
return jdbcTemplate.query("""
|
||||
select a.id, v.id version_id, v.simhash64
|
||||
from aihr_data_asset a
|
||||
join aihr_data_version v on v.tenant_id = a.tenant_id and v.id = a.current_version_id
|
||||
join aihr_data_version v on v.tenant_id = a.tenant_id
|
||||
and v.id = coalesce(a.candidate_version_id,
|
||||
a.published_version_id, a.current_version_id)
|
||||
where a.tenant_id = ? and a.id <> ? and v.simhash64 is not null
|
||||
order by a.update_time desc
|
||||
limit 2000
|
||||
@@ -756,13 +822,16 @@ public class AihrKnowledgeLifecycleService {
|
||||
throw new ServiceException("Invalid superseded asset", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
List<AssetRow> rows = jdbcTemplate.query("""
|
||||
select id, knowledge_id, doc_id, current_version_id, lifecycle_status, data_class,
|
||||
select id, knowledge_id, doc_id, coalesce(candidate_version_id, current_version_id) as current_version_id,
|
||||
published_version_id, candidate_version_id, lifecycle_status, data_class,
|
||||
source_version, source_name
|
||||
from aihr_data_asset
|
||||
where tenant_id = ? and id = ? for update
|
||||
""", (rs, rowNum) -> new AssetRow(rs.getLong("id"), rs.getLong("knowledge_id"), rs.getString("doc_id"),
|
||||
rs.getLong("current_version_id"), rs.getString("lifecycle_status"), rs.getString("data_class"),
|
||||
rs.getString("source_version"), rs.getString("source_name")), tenantId, supersededAssetId);
|
||||
rs.getLong("current_version_id"), rs.getObject("published_version_id", Long.class),
|
||||
rs.getObject("candidate_version_id", Long.class), rs.getString("lifecycle_status"),
|
||||
rs.getString("data_class"), rs.getString("source_version"), rs.getString("source_name")),
|
||||
tenantId, supersededAssetId);
|
||||
if (rows.isEmpty() || !Set.of("PUBLISHED", "APPROVED").contains(rows.get(0).lifecycleStatus())) {
|
||||
throw new ServiceException("Superseded asset is not currently published", HttpStatus.CONFLICT);
|
||||
}
|
||||
@@ -801,18 +870,115 @@ public class AihrKnowledgeLifecycleService {
|
||||
|
||||
private AssetRow lockAsset(String tenantId, long assetId) {
|
||||
List<AssetRow> rows = jdbcTemplate.query("""
|
||||
select id, knowledge_id, doc_id, current_version_id, lifecycle_status, data_class, source_version,
|
||||
select id, knowledge_id, doc_id, coalesce(candidate_version_id, current_version_id) as current_version_id,
|
||||
published_version_id, candidate_version_id, lifecycle_status, data_class, source_version,
|
||||
source_name
|
||||
from aihr_data_asset where tenant_id = ? and id = ? for update
|
||||
""", (rs, rowNum) -> new AssetRow(rs.getLong("id"), rs.getLong("knowledge_id"), rs.getString("doc_id"),
|
||||
rs.getLong("current_version_id"), rs.getString("lifecycle_status"), rs.getString("data_class"),
|
||||
rs.getString("source_version"), rs.getString("source_name")), tenantId, assetId);
|
||||
rs.getLong("current_version_id"), rs.getObject("published_version_id", Long.class),
|
||||
rs.getObject("candidate_version_id", Long.class), rs.getString("lifecycle_status"),
|
||||
rs.getString("data_class"), rs.getString("source_version"), rs.getString("source_name")), tenantId, assetId);
|
||||
if (rows.isEmpty()) {
|
||||
throw new ServiceException("Data asset not found", HttpStatus.NOT_FOUND);
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
public List<VersionSummary> versions(String tenantId, long assetId) {
|
||||
requireAsset(tenantId, assetId);
|
||||
return jdbcTemplate.query("""
|
||||
select v.id, v.version_no, v.version_status, v.revision_of_version_id, v.revision_reason,
|
||||
v.content_sha256, v.parser_name, v.parser_version, v.cleaning_policy_version,
|
||||
v.classification_policy_version, v.semantic_analysis_status, v.privacy_status,
|
||||
v.create_time, a.published_version_id, a.candidate_version_id,
|
||||
(select count(*) from aihr_chunk_revision c
|
||||
where c.tenant_id = v.tenant_id and c.version_id = v.id) chunk_count,
|
||||
(select count(*) from aihr_quality_issue q
|
||||
where q.tenant_id = v.tenant_id and q.version_id = v.id and q.status = 'OPEN') open_issue_count
|
||||
from aihr_data_version v
|
||||
join aihr_data_asset a on a.tenant_id = v.tenant_id and a.id = v.asset_id
|
||||
where v.tenant_id = ? and v.asset_id = ?
|
||||
order by v.version_no desc, v.id desc
|
||||
""", (rs, rowNum) -> {
|
||||
long versionId = rs.getLong("id");
|
||||
Long publishedVersionId = rs.getObject("published_version_id", Long.class);
|
||||
Long candidateVersionId = rs.getObject("candidate_version_id", Long.class);
|
||||
return new VersionSummary(versionId, rs.getInt("version_no"), rs.getString("version_status"),
|
||||
rs.getObject("revision_of_version_id", Long.class), rs.getString("revision_reason"),
|
||||
rs.getString("content_sha256"), rs.getString("parser_name"), rs.getString("parser_version"),
|
||||
rs.getString("cleaning_policy_version"), rs.getString("classification_policy_version"),
|
||||
rs.getString("semantic_analysis_status"), rs.getString("privacy_status"),
|
||||
publishedVersionId != null && versionId == publishedVersionId,
|
||||
candidateVersionId != null && versionId == candidateVersionId,
|
||||
rs.getInt("chunk_count"), rs.getInt("open_issue_count"),
|
||||
rs.getObject("create_time", LocalDateTime.class));
|
||||
}, tenantId, assetId);
|
||||
}
|
||||
|
||||
public VersionDetail versionDetail(String tenantId, long assetId, long versionId) {
|
||||
List<VersionDetail> rows = jdbcTemplate.query("""
|
||||
select v.id, v.version_no, v.version_status, v.revision_of_version_id, v.revision_reason,
|
||||
v.content_sha256, v.redacted_source_name, v.redacted_content, v.structure_profile_json,
|
||||
v.extraction_quality_json, v.redaction_summary_json, v.semantic_analysis_status,
|
||||
v.semantic_embedding_model, v.semantic_embedding_dimension, v.privacy_status,
|
||||
v.parser_name, v.parser_version, v.cleaning_policy_version,
|
||||
v.classification_policy_version, v.create_time,
|
||||
a.published_version_id, a.candidate_version_id
|
||||
from aihr_data_version v
|
||||
join aihr_data_asset a on a.tenant_id = v.tenant_id and a.id = v.asset_id
|
||||
where v.tenant_id = ? and v.asset_id = ? and v.id = ?
|
||||
limit 1
|
||||
""", (rs, rowNum) -> {
|
||||
long selectedVersionId = rs.getLong("id");
|
||||
Long publishedVersionId = rs.getObject("published_version_id", Long.class);
|
||||
Long candidateVersionId = rs.getObject("candidate_version_id", Long.class);
|
||||
return new VersionDetail(selectedVersionId, rs.getInt("version_no"), rs.getString("version_status"),
|
||||
rs.getObject("revision_of_version_id", Long.class), rs.getString("revision_reason"),
|
||||
rs.getString("content_sha256"), rs.getString("redacted_source_name"),
|
||||
rs.getString("redacted_content"), rs.getString("structure_profile_json"),
|
||||
rs.getString("extraction_quality_json"), rs.getString("redaction_summary_json"),
|
||||
rs.getString("semantic_analysis_status"), rs.getString("semantic_embedding_model"),
|
||||
rs.getObject("semantic_embedding_dimension", Integer.class), rs.getString("privacy_status"),
|
||||
rs.getString("parser_name"), rs.getString("parser_version"),
|
||||
rs.getString("cleaning_policy_version"), rs.getString("classification_policy_version"),
|
||||
publishedVersionId != null && selectedVersionId == publishedVersionId,
|
||||
candidateVersionId != null && selectedVersionId == candidateVersionId,
|
||||
List.of(), rs.getObject("create_time", LocalDateTime.class));
|
||||
},
|
||||
tenantId, assetId, versionId);
|
||||
if (rows.isEmpty()) {
|
||||
throw new ServiceException("Asset version does not exist", HttpStatus.NOT_FOUND);
|
||||
}
|
||||
return rows.get(0).withChunks(versionChunks(tenantId, versionId));
|
||||
}
|
||||
|
||||
private List<VersionChunk> versionChunks(String tenantId, long versionId) {
|
||||
return jdbcTemplate.query("""
|
||||
select id, chunk_index, content, content_sha256, heading_path, context_prefix,
|
||||
locator_json, published_fragment_id
|
||||
from aihr_chunk_revision
|
||||
where tenant_id = ? and version_id = ? order by chunk_index
|
||||
""", (rs, rowNum) -> new VersionChunk(rs.getLong("id"), rs.getInt("chunk_index"),
|
||||
rs.getString("content"), rs.getString("content_sha256"), rs.getString("heading_path"),
|
||||
rs.getString("context_prefix"), rs.getString("locator_json"),
|
||||
rs.getObject("published_fragment_id", Long.class)), tenantId, versionId);
|
||||
}
|
||||
|
||||
private void requireAsset(String tenantId, long assetId) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) from aihr_data_asset where tenant_id = ? and id = ?", Integer.class, tenantId, assetId);
|
||||
if (count == null || count == 0) {
|
||||
throw new ServiceException("Data asset not found", HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
private java.util.Optional<String> versionStatus(String tenantId, long versionId) {
|
||||
List<String> statuses = jdbcTemplate.queryForList("""
|
||||
select version_status from aihr_data_version where tenant_id = ? and id = ? limit 1
|
||||
""", String.class, tenantId, versionId);
|
||||
return statuses.stream().findFirst();
|
||||
}
|
||||
|
||||
private void acceptSoftFindings(String tenantId, long assetId, long versionId, long reviewerId,
|
||||
List<String> reasonCodes) {
|
||||
Set<String> accepted = new LinkedHashSet<>(reasonCodes == null ? List.of() : reasonCodes);
|
||||
@@ -844,22 +1010,6 @@ public class AihrKnowledgeLifecycleService {
|
||||
command.supersedesAssetId(), tenantId, assetId);
|
||||
}
|
||||
|
||||
private int removePublishedFragments(String tenantId, long assetId) {
|
||||
List<Long> ids = jdbcTemplate.queryForList("""
|
||||
select published_fragment_id from aihr_chunk_revision
|
||||
where tenant_id = ? and asset_id = ? and published_fragment_id is not null
|
||||
""", Long.class, tenantId, assetId);
|
||||
for (Long fragmentId : ids) {
|
||||
jdbcTemplate.update("delete from aihr_knowledge_fragment_locator where tenant_id = ? and fragment_id = ?",
|
||||
tenantId, fragmentId);
|
||||
jdbcTemplate.update("delete from aihr_knowledge_fragment where tenant_id = ? and id = ?", tenantId, fragmentId);
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
update aihr_chunk_revision set published_fragment_id = null where tenant_id = ? and asset_id = ?
|
||||
""", tenantId, assetId);
|
||||
return ids.size();
|
||||
}
|
||||
|
||||
private long insertPublishedFragment(String tenantId, long knowledgeId, String docId, long reviewerId,
|
||||
ChunkRow chunk) {
|
||||
KeyHolder keys = new GeneratedKeyHolder();
|
||||
@@ -929,12 +1079,26 @@ public class AihrKnowledgeLifecycleService {
|
||||
}
|
||||
|
||||
private void enqueueIndex(String tenantId, long assetId, long versionId, String operation, String docId) {
|
||||
IndexTarget target = jdbcTemplate.queryForObject("""
|
||||
select coalesce(scope.governed_collection, ?) collection_name,
|
||||
coalesce(scope.active_generation, 1) index_generation
|
||||
from aihr_data_asset asset
|
||||
left join aihr_knowledge_rollout_scope scope
|
||||
on scope.tenant_id = asset.tenant_id and scope.knowledge_id = asset.knowledge_id
|
||||
where asset.tenant_id = ? and asset.id = ?
|
||||
""", (rs, rowNum) -> new IndexTarget(rs.getString("collection_name"),
|
||||
rs.getLong("index_generation")), productionCollection, tenantId, assetId);
|
||||
if (target == null) {
|
||||
throw new ServiceException("Knowledge index target does not exist", HttpStatus.NOT_FOUND);
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
insert into aihr_index_outbox
|
||||
(tenant_id, asset_id, version_id, operation, target_collection, payload_json, status,
|
||||
(tenant_id, asset_id, version_id, operation, target_collection, index_generation,
|
||||
payload_json, status,
|
||||
attempt_count, next_attempt_time, create_time, update_time)
|
||||
values (?, ?, ?, ?, ?, ?, 'PENDING', 0, now(), now(), now())
|
||||
""", tenantId, assetId, versionId, operation, productionCollection, json(new IndexPayload(docId)));
|
||||
values (?, ?, ?, ?, ?, ?, ?, 'PENDING', 0, now(), now(), now())
|
||||
""", tenantId, assetId, versionId, operation, target.collection(), target.generation(),
|
||||
json(new IndexPayload(docId, assetId, versionId, target.generation())));
|
||||
}
|
||||
|
||||
private void addLineage(String tenantId, String fromType, String fromId, String toType, String toId,
|
||||
@@ -994,7 +1158,7 @@ public class AihrKnowledgeLifecycleService {
|
||||
}
|
||||
}
|
||||
|
||||
static String sha256(String value) {
|
||||
public static String sha256(String value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
@@ -1058,7 +1222,14 @@ public class AihrKnowledgeLifecycleService {
|
||||
public record ReviewCommand(String reason, String usageType, String sourceAuthority, String sourceVersion,
|
||||
String applicableRegion, String applicableProject, String applicableRole,
|
||||
LocalDate effectiveFrom, LocalDate effectiveTo, List<String> acceptedReasonCodes,
|
||||
Long supersedesAssetId) {
|
||||
Long supersedesAssetId, Long expectedVersionId) {
|
||||
public ReviewCommand(String reason, String usageType, String sourceAuthority, String sourceVersion,
|
||||
String applicableRegion, String applicableProject, String applicableRole,
|
||||
LocalDate effectiveFrom, LocalDate effectiveTo, List<String> acceptedReasonCodes,
|
||||
Long supersedesAssetId) {
|
||||
this(reason, usageType, sourceAuthority, sourceVersion, applicableRegion, applicableProject,
|
||||
applicableRole, effectiveFrom, effectiveTo, acceptedReasonCodes, supersedesAssetId, null);
|
||||
}
|
||||
}
|
||||
|
||||
public record PublishedAsset(long assetId, long versionId, String lifecycleStatus, String indexStatus,
|
||||
@@ -1084,8 +1255,40 @@ public class AihrKnowledgeLifecycleService {
|
||||
String redactedContentPreview) {
|
||||
}
|
||||
|
||||
private record AssetRow(long id, long knowledgeId, String docId, long versionId, String lifecycleStatus,
|
||||
String dataClass, String sourceVersion, String sourceName) {
|
||||
public record VersionSummary(long id, int versionNo, String versionStatus, Long revisionOfVersionId,
|
||||
String revisionReason, String contentSha256, String parserName,
|
||||
String parserVersion, String cleaningPolicyVersion,
|
||||
String classificationPolicyVersion, String semanticAnalysisStatus,
|
||||
String privacyStatus, boolean published, boolean candidate,
|
||||
int chunkCount, int openIssueCount, LocalDateTime createTime) {
|
||||
}
|
||||
|
||||
public record VersionDetail(long id, int versionNo, String versionStatus, Long revisionOfVersionId,
|
||||
String revisionReason, String contentSha256, String redactedSourceName,
|
||||
String redactedContent, String structureProfileJson, String extractionQualityJson,
|
||||
String redactionSummaryJson, String semanticAnalysisStatus,
|
||||
String semanticEmbeddingModel, Integer semanticEmbeddingDimension,
|
||||
String privacyStatus, String parserName, String parserVersion,
|
||||
String cleaningPolicyVersion, String classificationPolicyVersion,
|
||||
boolean published, boolean candidate, List<VersionChunk> chunks,
|
||||
LocalDateTime createTime) {
|
||||
private VersionDetail withChunks(List<VersionChunk> value) {
|
||||
return new VersionDetail(id, versionNo, versionStatus, revisionOfVersionId, revisionReason,
|
||||
contentSha256, redactedSourceName, redactedContent, structureProfileJson, extractionQualityJson,
|
||||
redactionSummaryJson, semanticAnalysisStatus, semanticEmbeddingModel, semanticEmbeddingDimension,
|
||||
privacyStatus, parserName, parserVersion, cleaningPolicyVersion, classificationPolicyVersion,
|
||||
published, candidate, value, createTime);
|
||||
}
|
||||
}
|
||||
|
||||
public record VersionChunk(long id, int chunkIndex, String content, String contentSha256,
|
||||
String headingPath, String contextPrefix, String locatorJson,
|
||||
Long publishedFragmentId) {
|
||||
}
|
||||
|
||||
private record AssetRow(long id, long knowledgeId, String docId, long versionId, Long publishedVersionId,
|
||||
Long candidateVersionId, String lifecycleStatus, String dataClass, String sourceVersion,
|
||||
String sourceName) {
|
||||
}
|
||||
|
||||
private record VersionGate(String semanticStatus, String privacyStatus, boolean privacyReady) {
|
||||
@@ -1104,7 +1307,10 @@ public class AihrKnowledgeLifecycleService {
|
||||
private record Snapshot(String lifecycleStatus, String dataClass, String sourceVersion) {
|
||||
}
|
||||
|
||||
private record IndexPayload(String docId) {
|
||||
private record IndexPayload(String docId, long assetId, long versionId, long generation) {
|
||||
}
|
||||
|
||||
private record IndexTarget(String collection, long generation) {
|
||||
}
|
||||
|
||||
private record DuplicateMatch(long assetId, long versionId) {
|
||||
|
||||
+298
@@ -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) {
|
||||
}
|
||||
}
|
||||
+164
@@ -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) {
|
||||
|
||||
+11
-6
@@ -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
|
||||
|
||||
+450
@@ -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) {
|
||||
}
|
||||
}
|
||||
+334
@@ -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) {
|
||||
}
|
||||
}
|
||||
+228
@@ -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) {
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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'
|
||||
|
||||
+434
@@ -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) {
|
||||
}
|
||||
}
|
||||
+305
@@ -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
-40
@@ -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<>();
|
||||
|
||||
+2
-1
@@ -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(),
|
||||
|
||||
+6
-20
@@ -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
|
||||
|
||||
+4
-2
@@ -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'
|
||||
|
||||
+266
-37
@@ -48,6 +48,7 @@ import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier;
|
||||
import org.dromara.aihr.knowledge.quality.AihrKnowledgeLifecycle.UsageType;
|
||||
import org.dromara.aihr.knowledge.quality.AihrKnowledgeLifecycle.ReasonCode;
|
||||
import org.dromara.aihr.knowledge.quality.AihrKnowledgeLifecycleService;
|
||||
import org.dromara.aihr.knowledge.quality.AihrKnowledgeRolloutService;
|
||||
import org.dromara.aihr.knowledge.quality.AihrKnowledgePrivacyService;
|
||||
import org.dromara.aihr.knowledge.quality.AihrKnowledgeTextProcessor;
|
||||
import org.dromara.aihr.personal.service.EnterpriseKnowledgeAccessPolicy.EnterpriseKnowledgeGrant;
|
||||
@@ -286,7 +287,7 @@ public class AihrSopSeedService {
|
||||
and match(f.content) against (? in natural language mode)
|
||||
order by score desc, f.id asc
|
||||
limit ?
|
||||
""".formatted(placeholders, publishedLifecycleScopeSql("f"));
|
||||
""".formatted(placeholders, servingLifecycleScopeSql("f"));
|
||||
List<Object> args = new ArrayList<>();
|
||||
args.add(query);
|
||||
args.add(grant.tenantId());
|
||||
@@ -706,7 +707,7 @@ public class AihrSopSeedService {
|
||||
and f.id in (%s)
|
||||
%s
|
||||
%s
|
||||
""".formatted(placeholders, knowledgeScopeSql("f", allowedKnowledgeIds), publishedLifecycleScopeSql("f"));
|
||||
""".formatted(placeholders, knowledgeScopeSql("f", allowedKnowledgeIds), servingLifecycleScopeSql("f"));
|
||||
List<Object> args = new ArrayList<>();
|
||||
args.add(tenantId());
|
||||
args.addAll(fragmentIds);
|
||||
@@ -872,6 +873,17 @@ public class AihrSopSeedService {
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public UploadResponse reprocessStagedAttachment(Long attachmentId, Path stagedFile) {
|
||||
return reprocessStagedAttachment(attachmentId, stagedFile, true);
|
||||
}
|
||||
|
||||
/** Stages a governed candidate from a ready legacy attachment without changing legacy service state. */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public UploadResponse stageLegacyAttachmentCandidate(Long attachmentId, Path stagedFile) {
|
||||
return reprocessStagedAttachment(attachmentId, stagedFile, false);
|
||||
}
|
||||
|
||||
private UploadResponse reprocessStagedAttachment(Long attachmentId, Path stagedFile,
|
||||
boolean updateLegacyAttachment) {
|
||||
if (attachmentId == null || attachmentId <= 0 || stagedFile == null || !Files.isRegularFile(stagedFile)) {
|
||||
throw new ServiceException("MEDIA_RETRY_SOURCE_MISSING: 待重试资料不存在");
|
||||
}
|
||||
@@ -897,7 +909,7 @@ public class AihrSopSeedService {
|
||||
|
||||
String docId = isBlank(attachment.docId())
|
||||
? UUID.randomUUID().toString().replace("-", "") : attachment.docId();
|
||||
if (!docId.equals(attachment.docId())) {
|
||||
if (updateLegacyAttachment && !docId.equals(attachment.docId())) {
|
||||
jdbcTemplate.update("""
|
||||
update aihr_knowledge_attach set doc_id = ?, update_time = now()
|
||||
where tenant_id = ? and id = ?
|
||||
@@ -912,8 +924,10 @@ public class AihrSopSeedService {
|
||||
AihrKnowledgeLifecycleService.StagedAsset staged = stageRawFailureCandidate(
|
||||
attachment.knowledgeId(), docId, attachment.ossId(), attachment.fileName(), sourceFingerprint,
|
||||
reasonCode, safeExtractionEvidence(error), AihrExtractionQuality.none());
|
||||
markAttachStatus(attachment.knowledgeId(), docId, 3,
|
||||
"quality-quarantined:asset=" + staged.assetId() + ":reason=" + reasonCode.name());
|
||||
if (updateLegacyAttachment) {
|
||||
markAttachStatus(attachment.knowledgeId(), docId, 3,
|
||||
"quality-quarantined:asset=" + staged.assetId() + ":reason=" + reasonCode.name());
|
||||
}
|
||||
return quarantinedUploadResponse(docId, attachment.ossId(), attachment.fileName(),
|
||||
attachment.spaceName(), reasonCode);
|
||||
}
|
||||
@@ -924,8 +938,10 @@ public class AihrSopSeedService {
|
||||
AihrKnowledgeLifecycleService.StagedAsset staged = stageRawFailureCandidate(
|
||||
attachment.knowledgeId(), docId, attachment.ossId(), attachment.fileName(), sourceFingerprint,
|
||||
reasonCode, "No usable text was extracted during reprocessing", extracted.quality());
|
||||
markAttachStatus(attachment.knowledgeId(), docId, 3,
|
||||
"quality-quarantined:asset=" + staged.assetId() + ":reason=" + reasonCode.name());
|
||||
if (updateLegacyAttachment) {
|
||||
markAttachStatus(attachment.knowledgeId(), docId, 3,
|
||||
"quality-quarantined:asset=" + staged.assetId() + ":reason=" + reasonCode.name());
|
||||
}
|
||||
return quarantinedUploadResponse(docId, attachment.ossId(), attachment.fileName(),
|
||||
attachment.spaceName(), reasonCode);
|
||||
}
|
||||
@@ -939,9 +955,11 @@ public class AihrSopSeedService {
|
||||
AihrKnowledgeLifecycleService.StagedAsset staged = stageCandidate(
|
||||
attachment.knowledgeId(), docId, attachment.ossId(), attachment.fileName(), content, fragments,
|
||||
fingerprint, extracted.quality(), chunkPlan.locators());
|
||||
updateOssInsight(attachment.ossId(), insight, fingerprint);
|
||||
markAttachStatus(attachment.knowledgeId(), docId, 2,
|
||||
"review-pending:asset=" + staged.assetId());
|
||||
if (updateLegacyAttachment) {
|
||||
updateOssInsight(attachment.ossId(), insight, fingerprint);
|
||||
markAttachStatus(attachment.knowledgeId(), docId, 2,
|
||||
"review-pending:asset=" + staged.assetId());
|
||||
}
|
||||
|
||||
List<SnippetResponse> snippets = fragments.stream()
|
||||
.limit(3)
|
||||
@@ -1133,7 +1151,7 @@ public class AihrSopSeedService {
|
||||
return new VectorIndexStatusResponse(
|
||||
runtime.modelName(),
|
||||
expectedDimension,
|
||||
qdrantCollection(),
|
||||
governedQdrantCollection(),
|
||||
qdrant.dimension(),
|
||||
qdrant.points(),
|
||||
db.fragments(),
|
||||
@@ -2220,7 +2238,8 @@ public class AihrSopSeedService {
|
||||
and a.index_status = 'READY' then a.id end) indexed_assets
|
||||
from aihr_data_asset a
|
||||
left join aihr_chunk_revision c on c.tenant_id = a.tenant_id
|
||||
and c.asset_id = a.id and c.version_id = a.current_version_id
|
||||
and c.asset_id = a.id
|
||||
and c.version_id = coalesce(a.published_version_id, a.current_version_id)
|
||||
left join aihr_knowledge_fragment f on f.tenant_id = c.tenant_id
|
||||
and f.id = c.published_fragment_id
|
||||
where a.tenant_id = ?
|
||||
@@ -2632,16 +2651,26 @@ public class AihrSopSeedService {
|
||||
}
|
||||
|
||||
public int vectorizePublishedDocument(long knowledgeId, String docId) {
|
||||
AihrKnowledgeRolloutService.ModeGeneration rollout = rolloutModeGeneration(knowledgeId);
|
||||
return vectorizePublishedDocument(knowledgeId, docId, null,
|
||||
firstNonBlank(rollout.governedCollection(), governedQdrantCollection()), rollout.activeGeneration());
|
||||
}
|
||||
|
||||
public int vectorizePublishedDocument(long knowledgeId, String docId, Long expectedVersionId,
|
||||
String targetCollection, long targetGeneration) {
|
||||
List<DocumentVectorRow> rows = jdbcTemplate.query("""
|
||||
select i.name, f.content
|
||||
from aihr_knowledge_fragment f
|
||||
join aihr_knowledge_info i on i.tenant_id = f.tenant_id and i.id = f.knowledge_id
|
||||
left join aihr_data_asset a on a.tenant_id = f.tenant_id
|
||||
and a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id
|
||||
where f.tenant_id = ? and f.knowledge_id = ? and f.doc_id = ?
|
||||
%s
|
||||
and (? is null or a.published_version_id = ?)
|
||||
order by f.idx
|
||||
""".formatted(publishedLifecycleScopeSql("f")),
|
||||
(rs, rowNum) -> new DocumentVectorRow(rs.getString(1), rs.getString(2)),
|
||||
tenantId(), knowledgeId, docId);
|
||||
tenantId(), knowledgeId, docId, expectedVersionId, expectedVersionId);
|
||||
if (rows.isEmpty()) {
|
||||
throw new ServiceException("Published document has no authorized fragments", 409);
|
||||
}
|
||||
@@ -2660,7 +2689,8 @@ public class AihrSopSeedService {
|
||||
data.embeddings().get(index), data.modelName(), tenantId(), knowledgeId, docId, index + 1);
|
||||
}
|
||||
try {
|
||||
upsertQdrant(knowledgeId, rows.get(0).category(), docId, fragments, data.embeddings(), data.modelName());
|
||||
upsertQdrant(knowledgeId, rows.get(0).category(), docId, fragments, data.embeddings(), data.modelName(),
|
||||
expectedVersionId, targetCollection, targetGeneration);
|
||||
} catch (Exception ex) {
|
||||
throw new ServiceException("Vector index upsert failed").setDetailMessage(ex.getMessage());
|
||||
}
|
||||
@@ -2668,11 +2698,18 @@ public class AihrSopSeedService {
|
||||
}
|
||||
|
||||
public void deleteVectorDocument(long knowledgeId, String docId) {
|
||||
deleteVectorDocument(knowledgeId, docId, governedQdrantCollection(), null);
|
||||
}
|
||||
|
||||
public void deleteVectorDocument(long knowledgeId, String docId, String targetCollection, Long generation) {
|
||||
try {
|
||||
ObjectNode body = objectMapper.createObjectNode();
|
||||
body.set("filter", qdrantFilter(knowledgeId, docId, null));
|
||||
body.set("filter", generation == null
|
||||
? qdrantFilter(knowledgeId, docId, null)
|
||||
: qdrantProductionFilterForSpaces(Set.of(knowledgeId), docId, null, generation));
|
||||
HttpResponse<String> response = qdrantRequest(
|
||||
"POST", "/collections/" + qdrantCollection() + "/points/delete?wait=true", body);
|
||||
"POST", "/collections/" + firstNonBlank(targetCollection, governedQdrantCollection())
|
||||
+ "/points/delete?wait=true", body);
|
||||
if (response.statusCode() != 404 && !ok(response.statusCode())) {
|
||||
throw new IllegalStateException("qdrant delete HTTP " + response.statusCode());
|
||||
}
|
||||
@@ -3520,6 +3557,10 @@ public class AihrSopSeedService {
|
||||
return "and " + publishedLifecycleExistsSql(fragmentAlias);
|
||||
}
|
||||
|
||||
private static String servingLifecycleScopeSql(String fragmentAlias) {
|
||||
return "and " + AihrKnowledgeRolloutService.servingFragmentExistsSql(fragmentAlias);
|
||||
}
|
||||
|
||||
private static String publishedLifecycleExistsSql(String fragmentAlias) {
|
||||
return """
|
||||
exists (
|
||||
@@ -3528,7 +3569,7 @@ public class AihrSopSeedService {
|
||||
join aihr_data_asset governed_asset
|
||||
on governed_asset.tenant_id = governed_chunk.tenant_id
|
||||
and governed_asset.id = governed_chunk.asset_id
|
||||
and governed_asset.current_version_id = governed_chunk.version_id
|
||||
and coalesce(governed_asset.published_version_id, governed_asset.current_version_id) = governed_chunk.version_id
|
||||
join aihr_dataset_membership governed_dataset
|
||||
on governed_dataset.tenant_id = governed_chunk.tenant_id
|
||||
and governed_dataset.version_id = governed_chunk.version_id
|
||||
@@ -3644,7 +3685,7 @@ public class AihrSopSeedService {
|
||||
order by score desc, f.idx asc
|
||||
limit ?
|
||||
""".formatted(knowledgeScopeSql("f", allowedKnowledgeIds),
|
||||
formalSourceScopeSql("f", formalPolicyOnly), publishedLifecycleScopeSql("f"));
|
||||
formalSourceScopeSql("f", formalPolicyOnly), servingLifecycleScopeSql("f"));
|
||||
List<Object> args = new ArrayList<>();
|
||||
args.add(queryText);
|
||||
args.add(tenantId());
|
||||
@@ -3707,7 +3748,7 @@ public class AihrSopSeedService {
|
||||
order by score desc, f.idx asc
|
||||
limit ?
|
||||
""".formatted(String.join(" + ", scoreParts), knowledgeScopeSql("f", allowedKnowledgeIds),
|
||||
formalSourceScopeSql("f", formalPolicyOnly), publishedLifecycleScopeSql("f"),
|
||||
formalSourceScopeSql("f", formalPolicyOnly), servingLifecycleScopeSql("f"),
|
||||
String.join(" or ", whereParts));
|
||||
List<Object> args = new ArrayList<>();
|
||||
terms.stream().map(AihrSopSeedService::likePattern).forEach(args::add);
|
||||
@@ -3755,14 +3796,16 @@ public class AihrSopSeedService {
|
||||
%s
|
||||
%s
|
||||
%s
|
||||
and f.knowledge_id = ?
|
||||
and f.doc_id = ?
|
||||
and f.idx = ?
|
||||
limit 1
|
||||
""".formatted(knowledgeScopeSql("f", allowedKnowledgeIds),
|
||||
formalSourceScopeSql("f", formalPolicyOnly), publishedLifecycleScopeSql("f"));
|
||||
formalSourceScopeSql("f", formalPolicyOnly), servingLifecycleScopeSql("f"));
|
||||
List<Object> args = new ArrayList<>();
|
||||
args.add(tenantId());
|
||||
addKnowledgeScopeArgs(args, allowedKnowledgeIds);
|
||||
args.add(match.knowledgeId());
|
||||
args.add(match.docId());
|
||||
args.add(match.idx());
|
||||
List<KnowledgeHit> rows = jdbcTemplate.query(sql, (rs, rowNum) -> new KnowledgeHit(
|
||||
@@ -4286,6 +4329,14 @@ public class AihrSopSeedService {
|
||||
}
|
||||
|
||||
private void upsertQdrant(long knowledgeId, String category, String docId, List<String> fragments, List<String> embeddings, String modelName) throws Exception {
|
||||
AihrKnowledgeRolloutService.ModeGeneration rollout = rolloutModeGeneration(knowledgeId);
|
||||
upsertQdrant(knowledgeId, category, docId, fragments, embeddings, modelName, null,
|
||||
firstNonBlank(rollout.governedCollection(), governedQdrantCollection()), rollout.activeGeneration());
|
||||
}
|
||||
|
||||
private void upsertQdrant(long knowledgeId, String category, String docId, List<String> fragments,
|
||||
List<String> embeddings, String modelName, Long expectedVersionId,
|
||||
String targetCollection, long targetGeneration) throws Exception {
|
||||
if (fragments.isEmpty() || embeddings.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -4293,9 +4344,41 @@ public class AihrSopSeedService {
|
||||
if (!firstVector.isArray() || firstVector.size() == 0) {
|
||||
return;
|
||||
}
|
||||
ensureQdrantCollection(firstVector.size());
|
||||
String governedCollection = firstNonBlank(targetCollection, governedQdrantCollection());
|
||||
ensureQdrantCollection(governedCollection, firstVector.size());
|
||||
|
||||
long generation = Math.max(1L, targetGeneration);
|
||||
Map<Integer, GovernedVectorLineage> governedLineage = jdbcTemplate.query("""
|
||||
select a.id asset_id, c.version_id, c.id chunk_revision_id, c.chunk_index,
|
||||
c.content_sha256, c.locator_json, c.published_fragment_id
|
||||
from aihr_data_asset a
|
||||
join aihr_chunk_revision c on c.tenant_id = a.tenant_id and c.asset_id = a.id
|
||||
and c.version_id = coalesce(a.published_version_id, a.current_version_id)
|
||||
where a.tenant_id = ? and a.knowledge_id = ? and a.doc_id = ?
|
||||
and a.lifecycle_status = 'PUBLISHED' and a.trust_level = 'HUMAN_VERIFIED'
|
||||
and (? is null or c.version_id = ?)
|
||||
order by c.chunk_index
|
||||
""", rs -> {
|
||||
Map<Integer, GovernedVectorLineage> result = new LinkedHashMap<>();
|
||||
while (rs.next()) {
|
||||
result.put(rs.getInt("chunk_index"), new GovernedVectorLineage(
|
||||
rs.getLong("asset_id"), rs.getLong("version_id"), rs.getLong("chunk_revision_id"),
|
||||
rs.getString("content_sha256"), rs.getString("locator_json"),
|
||||
rs.getObject("published_fragment_id", Long.class)));
|
||||
}
|
||||
return result;
|
||||
}, tenantId(), knowledgeId, docId, expectedVersionId, expectedVersionId);
|
||||
|
||||
ObjectNode deleteBody = objectMapper.createObjectNode();
|
||||
deleteBody.set("filter", qdrantProductionFilterForSpaces(Set.of(knowledgeId), docId, null, generation));
|
||||
HttpResponse<String> deleteResponse = qdrantRequest(
|
||||
"POST", "/collections/" + governedCollection + "/points/delete?wait=true", deleteBody);
|
||||
if (deleteResponse.statusCode() != 404 && !ok(deleteResponse.statusCode())) {
|
||||
throw new IllegalStateException("qdrant generation cleanup HTTP " + deleteResponse.statusCode());
|
||||
}
|
||||
|
||||
ArrayNode points = objectMapper.createArrayNode();
|
||||
Map<Long, String> pointIds = new LinkedHashMap<>();
|
||||
int size = Math.min(fragments.size(), embeddings.size());
|
||||
for (int i = 0; i < size; i++) {
|
||||
JsonNode vector = objectMapper.readTree(embeddings.get(i));
|
||||
@@ -4312,9 +4395,29 @@ public class AihrSopSeedService {
|
||||
payload.put("dataset_code", "production");
|
||||
payload.put("lifecycle_status", "PUBLISHED");
|
||||
payload.put("trust_level", "HUMAN_VERIFIED");
|
||||
payload.put("index_generation", generation);
|
||||
payload.put("content_sha256", AihrKnowledgeLifecycleService.sha256(fragments.get(i)));
|
||||
|
||||
GovernedVectorLineage lineage = governedLineage.get(i + 1);
|
||||
String pointId = qdrantPointId(knowledgeId, docId, i + 1);
|
||||
if (lineage != null) {
|
||||
payload.put("asset_id", lineage.assetId());
|
||||
payload.put("version_id", lineage.versionId());
|
||||
payload.put("chunk_revision_id", lineage.chunkRevisionId());
|
||||
if (lineage.publishedFragmentId() != null) {
|
||||
payload.put("fragment_id", lineage.publishedFragmentId());
|
||||
}
|
||||
payload.put("content_sha256", lineage.contentSha256());
|
||||
if (!isBlank(lineage.locatorJson())) {
|
||||
payload.set("locator", objectMapper.readTree(lineage.locatorJson()));
|
||||
}
|
||||
pointId = governedQdrantPointId(lineage.assetId(), lineage.versionId(),
|
||||
lineage.chunkRevisionId(), generation);
|
||||
pointIds.put(lineage.chunkRevisionId(), pointId);
|
||||
}
|
||||
|
||||
ObjectNode point = objectMapper.createObjectNode();
|
||||
point.put("id", qdrantPointId(knowledgeId, docId, i + 1));
|
||||
point.put("id", pointId);
|
||||
point.set("vector", vector);
|
||||
point.set("payload", payload);
|
||||
points.add(point);
|
||||
@@ -4325,10 +4428,14 @@ public class AihrSopSeedService {
|
||||
|
||||
ObjectNode body = objectMapper.createObjectNode();
|
||||
body.set("points", points);
|
||||
HttpResponse<String> response = qdrantRequest("PUT", "/collections/" + qdrantCollection() + "/points?wait=true", body);
|
||||
HttpResponse<String> response = qdrantRequest("PUT", "/collections/" + governedCollection + "/points?wait=true", body);
|
||||
if (!ok(response.statusCode())) {
|
||||
throw new IllegalStateException("qdrant upsert HTTP " + response.statusCode());
|
||||
}
|
||||
pointIds.forEach((chunkRevisionId, pointId) -> jdbcTemplate.update("""
|
||||
update aihr_chunk_revision set index_generation = ?, vector_point_id = ?
|
||||
where tenant_id = ? and id = ?
|
||||
""", generation, pointId, tenantId(), chunkRevisionId));
|
||||
}
|
||||
|
||||
private void deleteQdrantDoc(long knowledgeId, String docId) {
|
||||
@@ -4339,7 +4446,7 @@ public class AihrSopSeedService {
|
||||
try {
|
||||
ObjectNode body = objectMapper.createObjectNode();
|
||||
body.set("filter", qdrantFilter(knowledgeId, docId, null));
|
||||
HttpResponse<String> response = qdrantRequest("POST", "/collections/" + qdrantCollection() + "/points/delete?wait=true", body);
|
||||
HttpResponse<String> response = qdrantRequest("POST", "/collections/" + governedQdrantCollection() + "/points/delete?wait=true", body);
|
||||
if (response.statusCode() == 404) {
|
||||
return;
|
||||
}
|
||||
@@ -4370,14 +4477,46 @@ public class AihrSopSeedService {
|
||||
}
|
||||
|
||||
private List<VectorMatch> queryQdrant(String category, String embeddingJson, int limit, Set<Long> allowedKnowledgeIds) throws Exception {
|
||||
List<RolloutVectorScope> scopes = rolloutVectorScopes(allowedKnowledgeIds);
|
||||
List<VectorMatch> matches = new ArrayList<>();
|
||||
Set<Long> legacySpaces = new LinkedHashSet<>();
|
||||
Map<String, Set<Long>> governedSpaces = new LinkedHashMap<>();
|
||||
for (RolloutVectorScope scope : scopes) {
|
||||
if ("ENFORCED".equals(scope.mode())) {
|
||||
String key = scope.collection() + "\n" + scope.generation();
|
||||
governedSpaces.computeIfAbsent(key, ignored -> new LinkedHashSet<>()).add(scope.knowledgeId());
|
||||
} else {
|
||||
legacySpaces.add(scope.knowledgeId());
|
||||
}
|
||||
}
|
||||
if (!legacySpaces.isEmpty()) {
|
||||
matches.addAll(queryQdrantCollection(legacyQdrantCollection(), category, embeddingJson,
|
||||
limit, legacySpaces, null));
|
||||
}
|
||||
for (Map.Entry<String, Set<Long>> entry : governedSpaces.entrySet()) {
|
||||
int split = entry.getKey().lastIndexOf('\n');
|
||||
String collection = entry.getKey().substring(0, split);
|
||||
long generation = Long.parseLong(entry.getKey().substring(split + 1));
|
||||
matches.addAll(queryQdrantCollection(collection, category, embeddingJson,
|
||||
limit, entry.getValue(), generation));
|
||||
}
|
||||
return matches.stream().sorted(Comparator.comparingDouble(VectorMatch::score).reversed())
|
||||
.limit(limit).toList();
|
||||
}
|
||||
|
||||
private List<VectorMatch> queryQdrantCollection(String collection, String category, String embeddingJson,
|
||||
int limit, Set<Long> knowledgeIds,
|
||||
Long generation) throws Exception {
|
||||
ObjectNode body = objectMapper.createObjectNode();
|
||||
body.set("query", objectMapper.readTree(embeddingJson));
|
||||
body.set("filter", qdrantProductionFilterForSpaces(allowedKnowledgeIds, null, category));
|
||||
body.set("filter", generation == null
|
||||
? qdrantFilterForSpaces(knowledgeIds, null, category)
|
||||
: qdrantProductionFilterForSpaces(knowledgeIds, null, category, generation));
|
||||
body.put("limit", limit);
|
||||
body.put("with_payload", true);
|
||||
body.put("with_vector", false);
|
||||
|
||||
HttpResponse<String> response = qdrantRequest("POST", "/collections/" + qdrantCollection() + "/points/query", body);
|
||||
HttpResponse<String> response = qdrantRequest("POST", "/collections/" + collection + "/points/query", body);
|
||||
if (!ok(response.statusCode())) {
|
||||
return List.of();
|
||||
}
|
||||
@@ -4388,15 +4527,78 @@ public class AihrSopSeedService {
|
||||
List<VectorMatch> matches = new ArrayList<>();
|
||||
for (JsonNode point : points) {
|
||||
JsonNode payload = point.path("payload");
|
||||
long knowledgeId = payload.path("knowledge_id").asLong(0L);
|
||||
String docId = payload.path("doc_id").asText("");
|
||||
int idx = payload.path("idx").asInt(0);
|
||||
if (!docId.isBlank() && idx > 0) {
|
||||
matches.add(new VectorMatch(docId, idx, point.path("score").asDouble()));
|
||||
if (knowledgeId > 0 && !docId.isBlank() && idx > 0) {
|
||||
matches.add(new VectorMatch(knowledgeId, docId, idx, point.path("score").asDouble()));
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
private List<RolloutVectorScope> rolloutVectorScopes(Set<Long> allowedKnowledgeIds) {
|
||||
String idScope = allowedKnowledgeIds == null || allowedKnowledgeIds.isEmpty()
|
||||
? "" : "and k.id in (" + String.join(",",
|
||||
java.util.Collections.nCopies(allowedKnowledgeIds.size(), "?")) + ")";
|
||||
List<Object> args = new ArrayList<>();
|
||||
args.add(AihrKnowledgeRolloutService.DEFAULT_GOVERNED_COLLECTION);
|
||||
args.add(tenantId());
|
||||
if (allowedKnowledgeIds != null && !allowedKnowledgeIds.isEmpty()) {
|
||||
args.addAll(allowedKnowledgeIds);
|
||||
}
|
||||
try {
|
||||
return jdbcTemplate.query("""
|
||||
select k.id knowledge_id, coalesce(s.mode, 'LEGACY') mode,
|
||||
coalesce(s.governed_collection, ?) governed_collection,
|
||||
coalesce(s.active_generation, 1) active_generation
|
||||
from aihr_knowledge_info k
|
||||
left join aihr_knowledge_rollout_scope s
|
||||
on s.tenant_id = k.tenant_id and s.knowledge_id = k.id
|
||||
where k.tenant_id = ? and k.status = 'ACTIVE' %s
|
||||
order by k.id
|
||||
""".formatted(idScope), (rs, rowNum) -> new RolloutVectorScope(
|
||||
rs.getLong("knowledge_id"), rs.getString("mode"),
|
||||
rs.getString("governed_collection"), rs.getLong("active_generation")), args.toArray());
|
||||
} catch (DataAccessException ignored) {
|
||||
return legacyVectorScopes(allowedKnowledgeIds);
|
||||
}
|
||||
}
|
||||
|
||||
private List<RolloutVectorScope> legacyVectorScopes(Set<Long> allowedKnowledgeIds) {
|
||||
if (allowedKnowledgeIds != null && !allowedKnowledgeIds.isEmpty()) {
|
||||
return allowedKnowledgeIds.stream().map(id -> new RolloutVectorScope(id, "LEGACY",
|
||||
AihrKnowledgeRolloutService.DEFAULT_GOVERNED_COLLECTION, 1L)).toList();
|
||||
}
|
||||
try {
|
||||
return jdbcTemplate.queryForList("""
|
||||
select id from aihr_knowledge_info where tenant_id = ? and status = 'ACTIVE'
|
||||
""", Long.class, tenantId()).stream().map(id -> new RolloutVectorScope(id, "LEGACY",
|
||||
AihrKnowledgeRolloutService.DEFAULT_GOVERNED_COLLECTION, 1L)).toList();
|
||||
} catch (DataAccessException ignored) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private AihrKnowledgeRolloutService.ModeGeneration rolloutModeGeneration(long knowledgeId) {
|
||||
try {
|
||||
List<AihrKnowledgeRolloutService.ModeGeneration> rows = jdbcTemplate.query("""
|
||||
select mode, governed_collection, active_generation
|
||||
from aihr_knowledge_rollout_scope
|
||||
where tenant_id = ? and knowledge_id = ? limit 1
|
||||
""", (rs, rowNum) -> new AihrKnowledgeRolloutService.ModeGeneration(
|
||||
rs.getString("mode"), rs.getString("governed_collection"),
|
||||
rs.getLong("active_generation")), tenantId(), knowledgeId);
|
||||
if (!rows.isEmpty()) {
|
||||
return rows.get(0);
|
||||
}
|
||||
} catch (DataAccessException ignored) {
|
||||
// Schema can be installed before or together with the compatibility JAR.
|
||||
}
|
||||
return new AihrKnowledgeRolloutService.ModeGeneration("LEGACY",
|
||||
AihrKnowledgeRolloutService.DEFAULT_GOVERNED_COLLECTION, 1L);
|
||||
}
|
||||
|
||||
private VectorDbStats vectorDbStats() {
|
||||
try {
|
||||
return jdbcTemplate.queryForObject("""
|
||||
@@ -4438,7 +4640,7 @@ public class AihrSopSeedService {
|
||||
|
||||
private QdrantStats qdrantStats() {
|
||||
try {
|
||||
HttpResponse<String> response = qdrantRequest("GET", "/collections/" + qdrantCollection(), null);
|
||||
HttpResponse<String> response = qdrantRequest("GET", "/collections/" + governedQdrantCollection(), null);
|
||||
if (response.statusCode() == 404) {
|
||||
return new QdrantStats(null, 0L, "Qdrant collection 不存在");
|
||||
}
|
||||
@@ -4456,8 +4658,8 @@ public class AihrSopSeedService {
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureQdrantCollection(int vectorSize) throws Exception {
|
||||
HttpResponse<String> current = qdrantRequest("GET", "/collections/" + qdrantCollection(), null);
|
||||
private void ensureQdrantCollection(String collection, int vectorSize) throws Exception {
|
||||
HttpResponse<String> current = qdrantRequest("GET", "/collections/" + collection, null);
|
||||
if (ok(current.statusCode())) {
|
||||
int currentSize = objectMapper.readTree(current.body())
|
||||
.path("result")
|
||||
@@ -4480,7 +4682,7 @@ public class AihrSopSeedService {
|
||||
vectors.put("distance", "Cosine");
|
||||
ObjectNode body = objectMapper.createObjectNode();
|
||||
body.set("vectors", vectors);
|
||||
HttpResponse<String> created = qdrantRequest("PUT", "/collections/" + qdrantCollection(), body);
|
||||
HttpResponse<String> created = qdrantRequest("PUT", "/collections/" + collection, body);
|
||||
if (!ok(created.statusCode())) {
|
||||
throw new IllegalStateException("qdrant create collection HTTP " + created.statusCode());
|
||||
}
|
||||
@@ -4490,7 +4692,7 @@ public class AihrSopSeedService {
|
||||
try {
|
||||
ObjectNode body = objectMapper.createObjectNode();
|
||||
body.set("filter", qdrantProductionFilterForSpaces(null, null, null));
|
||||
HttpResponse<String> response = qdrantRequest("POST", "/collections/" + qdrantCollection() + "/points/delete?wait=true", body);
|
||||
HttpResponse<String> response = qdrantRequest("POST", "/collections/" + governedQdrantCollection() + "/points/delete?wait=true", body);
|
||||
if (response.statusCode() == 404) {
|
||||
return;
|
||||
}
|
||||
@@ -4507,7 +4709,7 @@ public class AihrSopSeedService {
|
||||
ObjectNode body = objectMapper.createObjectNode();
|
||||
body.set("filter", qdrantProductionFilterForSpaces(null, null, null));
|
||||
body.put("exact", true);
|
||||
HttpResponse<String> response = qdrantRequest("POST", "/collections/" + qdrantCollection() + "/points/count", body);
|
||||
HttpResponse<String> response = qdrantRequest("POST", "/collections/" + governedQdrantCollection() + "/points/count", body);
|
||||
if (!ok(response.statusCode())) {
|
||||
return null;
|
||||
}
|
||||
@@ -4539,11 +4741,19 @@ public class AihrSopSeedService {
|
||||
}
|
||||
|
||||
private ObjectNode qdrantProductionFilterForSpaces(Set<Long> knowledgeIds, String docId, String category) {
|
||||
return qdrantProductionFilterForSpaces(knowledgeIds, docId, category, null);
|
||||
}
|
||||
|
||||
private ObjectNode qdrantProductionFilterForSpaces(Set<Long> knowledgeIds, String docId, String category,
|
||||
Long generation) {
|
||||
ObjectNode filter = qdrantFilterForSpaces(knowledgeIds, docId, category);
|
||||
ArrayNode must = (ArrayNode) filter.path("must");
|
||||
must.add(qdrantMatch("dataset_code", "production"));
|
||||
must.add(qdrantMatch("lifecycle_status", "PUBLISHED"));
|
||||
must.add(qdrantMatch("trust_level", "HUMAN_VERIFIED"));
|
||||
if (generation != null) {
|
||||
must.add(qdrantMatch("index_generation", generation));
|
||||
}
|
||||
return filter;
|
||||
}
|
||||
|
||||
@@ -4968,8 +5178,15 @@ public class AihrSopSeedService {
|
||||
return normalizeBaseUrl(firstNonBlank(System.getProperty("aihr.qdrant.url"), System.getenv("AIHR_QDRANT_URL"), "http://127.0.0.1:6333"));
|
||||
}
|
||||
|
||||
private static String qdrantCollection() {
|
||||
return firstNonBlank(System.getProperty("aihr.qdrant.collection"), System.getenv("AIHR_QDRANT_COLLECTION"), "aihr_knowledge");
|
||||
private static String legacyQdrantCollection() {
|
||||
return firstNonBlank(System.getProperty("aihr.qdrant.collection"),
|
||||
System.getenv("AIHR_QDRANT_COLLECTION"), "aihr_knowledge");
|
||||
}
|
||||
|
||||
private static String governedQdrantCollection() {
|
||||
return firstNonBlank(System.getProperty("aihr.qdrant.governed-collection"),
|
||||
System.getenv("AIHR_QDRANT_GOVERNED_COLLECTION"),
|
||||
AihrKnowledgeRolloutService.DEFAULT_GOVERNED_COLLECTION);
|
||||
}
|
||||
|
||||
private static String qdrantApiKey() {
|
||||
@@ -4980,6 +5197,11 @@ public class AihrSopSeedService {
|
||||
return UUID.nameUUIDFromBytes((tenantId() + ":" + knowledgeId + ":" + docId + ":" + idx).getBytes(StandardCharsets.UTF_8)).toString();
|
||||
}
|
||||
|
||||
private String governedQdrantPointId(long assetId, long versionId, long chunkRevisionId, long generation) {
|
||||
return UUID.nameUUIDFromBytes((tenantId() + ":governed:" + assetId + ":" + versionId + ":"
|
||||
+ chunkRevisionId + ":" + generation).getBytes(StandardCharsets.UTF_8)).toString();
|
||||
}
|
||||
|
||||
private static boolean ok(int statusCode) {
|
||||
return statusCode >= 200 && statusCode < 300;
|
||||
}
|
||||
@@ -5185,6 +5407,10 @@ public class AihrSopSeedService {
|
||||
private record DocumentVectorRow(String category, String content) {
|
||||
}
|
||||
|
||||
private record GovernedVectorLineage(long assetId, long versionId, long chunkRevisionId,
|
||||
String contentSha256, String locatorJson, Long publishedFragmentId) {
|
||||
}
|
||||
|
||||
private record DuplicateHit(long attachId, long knowledgeId, String docId, long ossId, String matchType) {
|
||||
}
|
||||
|
||||
@@ -5198,7 +5424,10 @@ public class AihrSopSeedService {
|
||||
private record VideoMarker(boolean frame, long startMs, Long endMs) {
|
||||
}
|
||||
|
||||
private record VectorMatch(String docId, int idx, double score) {
|
||||
private record VectorMatch(long knowledgeId, String docId, int idx, double score) {
|
||||
}
|
||||
|
||||
private record RolloutVectorScope(long knowledgeId, String mode, String collection, long generation) {
|
||||
}
|
||||
|
||||
private record LocalImportPlan(Path root, String directory, String category, List<Path> files) {
|
||||
|
||||
+2
-5
@@ -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
|
||||
|
||||
+2
-7
@@ -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
|
||||
|
||||
+3
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+88
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+73
@@ -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);
|
||||
}
|
||||
}
|
||||
+14
@@ -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 {
|
||||
|
||||
+53
@@ -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());
|
||||
}
|
||||
}
|
||||
+35
@@ -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();
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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;
|
||||
Reference in New Issue
Block a user