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 JdbcTemplate jdbcTemplate;
|
||||||
private final AihrSopSeedService sopSeedService;
|
private final AihrSopSeedService sopSeedService;
|
||||||
|
|
||||||
@Value("${aihr.qdrant.collection:${AIHR_QDRANT_COLLECTION:aihr_knowledge}}")
|
@Value("${aihr.qdrant.governed-collection:${AIHR_QDRANT_GOVERNED_COLLECTION:aihr_knowledge_governed_v1}}")
|
||||||
private String productionCollection = "aihr_knowledge";
|
private String productionCollection = AihrKnowledgeRolloutService.DEFAULT_GOVERNED_COLLECTION;
|
||||||
|
|
||||||
@Value("${aihr.knowledge.index-outbox-max-attempts:8}")
|
@Value("${aihr.knowledge.index-outbox-max-attempts:8}")
|
||||||
private int maxAttempts = 8;
|
private int maxAttempts = 8;
|
||||||
@@ -36,34 +36,47 @@ public class AihrKnowledgeIndexOutboxService {
|
|||||||
""");
|
""");
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
insert into aihr_index_outbox
|
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)
|
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,
|
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
|
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')
|
where a.lifecycle_status in ('PUBLISHED', 'DEPRECATED')
|
||||||
and a.index_status in ('PENDING', 'FAILED')
|
and a.index_status in ('PENDING', 'FAILED')
|
||||||
and not exists (
|
and not exists (
|
||||||
select 1 from aihr_index_outbox active_job
|
select 1 from aihr_index_outbox active_job
|
||||||
where active_job.tenant_id = a.tenant_id and active_job.asset_id = a.id
|
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')
|
and active_job.status in ('PENDING', 'PROCESSING', 'FAILED', 'DEAD_LETTER')
|
||||||
)
|
)
|
||||||
""", productionCollection);
|
""", productionCollection);
|
||||||
List<OutboxRow> rows = jdbcTemplate.query("""
|
List<OutboxRow> rows = jdbcTemplate.query("""
|
||||||
select o.id, o.tenant_id, o.asset_id, o.version_id, o.operation,
|
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,
|
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
|
from aihr_index_outbox o
|
||||||
join aihr_data_asset a on a.tenant_id = o.tenant_id and a.id = o.asset_id
|
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()
|
where o.status in ('PENDING', 'FAILED') and o.next_attempt_time <= now()
|
||||||
order by o.id
|
order by o.id
|
||||||
limit 20
|
limit 20
|
||||||
""", (rs, rowNum) -> new OutboxRow(rs.getLong("id"), rs.getString("tenant_id"),
|
""", (rs, rowNum) -> new OutboxRow(rs.getLong("id"), rs.getString("tenant_id"),
|
||||||
rs.getLong("asset_id"), rs.getLong("version_id"), rs.getString("operation"),
|
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("target_collection"), rs.getLong("index_generation"), rs.getInt("attempt_count"),
|
||||||
rs.getString("lifecycle_status"), rs.getLong("current_version_id")));
|
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);
|
rows.forEach(this::claimAndProcess);
|
||||||
} catch (RuntimeException ex) {
|
} catch (RuntimeException ex) {
|
||||||
// The migration is additive. A pre-migration process must continue serving non-knowledge APIs.
|
// The migration is additive. A pre-migration process must continue serving non-knowledge APIs.
|
||||||
@@ -91,10 +104,12 @@ public class AihrKnowledgeIndexOutboxService {
|
|||||||
try {
|
try {
|
||||||
Integer vectors = TenantHelper.dynamic(row.tenantId(), () -> {
|
Integer vectors = TenantHelper.dynamic(row.tenantId(), () -> {
|
||||||
if ("DELETE".equals(row.operation())) {
|
if ("DELETE".equals(row.operation())) {
|
||||||
sopSeedService.deleteVectorDocument(row.knowledgeId(), row.docId());
|
sopSeedService.deleteVectorDocument(row.knowledgeId(), row.docId(),
|
||||||
|
row.collection(), row.generation());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return sopSeedService.vectorizePublishedDocument(row.knowledgeId(), row.docId());
|
return sopSeedService.vectorizePublishedDocument(row.knowledgeId(), row.docId(),
|
||||||
|
row.versionId(), row.collection(), row.generation());
|
||||||
});
|
});
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
update aihr_index_outbox
|
update aihr_index_outbox
|
||||||
@@ -104,9 +119,53 @@ public class AihrKnowledgeIndexOutboxService {
|
|||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
update aihr_data_asset
|
update aihr_data_asset
|
||||||
set index_status = ?, update_time = now()
|
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",
|
""", "DELETE".equals(row.operation()) || vectors == null || vectors == 0 ? "NOT_REQUIRED" : "READY",
|
||||||
row.tenantId(), row.assetId(), row.versionId());
|
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) {
|
} catch (RuntimeException ex) {
|
||||||
String message = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage();
|
String message = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage();
|
||||||
int attempted = row.attemptCount() + 1;
|
int attempted = row.attemptCount() + 1;
|
||||||
@@ -128,7 +187,8 @@ public class AihrKnowledgeIndexOutboxService {
|
|||||||
}
|
}
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
update aihr_data_asset set index_status = 'FAILED', update_time = now()
|
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());
|
""", row.tenantId(), row.assetId(), row.versionId());
|
||||||
if ("DEAD_LETTER".equals(failureStatus)) {
|
if ("DEAD_LETTER".equals(failureStatus)) {
|
||||||
log.error("knowledge index outbox dead-lettered id={} operation={} attempts={} reason={}",
|
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,
|
private record OutboxRow(long id, String tenantId, long assetId, long versionId, String operation,
|
||||||
int attemptCount, long knowledgeId, String docId, String lifecycleStatus,
|
String collection, long generation, int attemptCount, long knowledgeId,
|
||||||
long currentVersionId) {
|
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.nio.file.Path;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -39,19 +41,39 @@ public class AihrKnowledgeLegacyMigrationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public MigrationPreview previewBatch(String tenantId, long afterAttachmentId, int limit) {
|
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));
|
int boundedLimit = Math.max(1, Math.min(limit, 100));
|
||||||
long cursor = Math.max(0L, afterAttachmentId);
|
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();
|
long nextCursor = rows.isEmpty() ? cursor : rows.get(rows.size() - 1).attachmentId();
|
||||||
int sourceUnavailable = (int) rows.stream().filter(row -> row.ossId() == null).count();
|
int sourceUnavailable = (int) rows.stream().filter(row -> row.ossId() == null).count();
|
||||||
return new MigrationPreview(rows.size(), rows.size() - sourceUnavailable, sourceUnavailable,
|
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) {
|
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));
|
int boundedLimit = Math.max(1, Math.min(limit, 100));
|
||||||
long cursor = Math.max(0L, afterAttachmentId);
|
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> stagedAssetIds = new ArrayList<>();
|
||||||
List<Long> failedAttachmentIds = 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,
|
return new MigrationResult(rows.size(), stagedAssetIds.size(), reparsed, quarantined,
|
||||||
List.copyOf(stagedAssetIds), List.copyOf(failedAttachmentIds), nextCursor, moreAvailable);
|
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("""
|
return jdbcTemplate.query("""
|
||||||
select a.id, a.knowledge_id, a.doc_id,
|
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,
|
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
|
coalesce(a.name, a.doc_id) source_name, a.create_by
|
||||||
from aihr_knowledge_attach a
|
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
|
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 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 (
|
and not exists (
|
||||||
select 1 from aihr_data_asset governed
|
select 1 from aihr_data_asset governed
|
||||||
where governed.tenant_id = a.tenant_id and governed.knowledge_id = a.knowledge_id
|
where governed.tenant_id = a.tenant_id and governed.knowledge_id = a.knowledge_id
|
||||||
@@ -113,7 +138,8 @@ public class AihrKnowledgeLegacyMigrationService {
|
|||||||
limit ?
|
limit ?
|
||||||
""", (rs, rowNum) -> new LegacyAttachment(rs.getLong("id"), rs.getLong("knowledge_id"),
|
""", (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.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 {
|
private UploadResponse restageFromRaw(String tenantId, LegacyAttachment row) throws IOException {
|
||||||
@@ -130,7 +156,7 @@ public class AihrKnowledgeLegacyMigrationService {
|
|||||||
copyBounded(input, output, MAX_RAW_BYTES);
|
copyBounded(input, output, MAX_RAW_BYTES);
|
||||||
}
|
}
|
||||||
return TenantHelper.dynamic(tenantId,
|
return TenantHelper.dynamic(tenantId,
|
||||||
() -> sopSeedService.reprocessStagedAttachment(row.attachmentId(), staged));
|
() -> sopSeedService.stageLegacyAttachmentCandidate(row.attachmentId(), staged));
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
Files.deleteIfExists(staged);
|
Files.deleteIfExists(staged);
|
||||||
@@ -158,20 +184,32 @@ public class AihrKnowledgeLegacyMigrationService {
|
|||||||
return assetIds.isEmpty() ? null : assetIds.get(0);
|
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("""
|
Integer count = jdbcTemplate.queryForObject("""
|
||||||
select count(*) from aihr_knowledge_attach a
|
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 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 (
|
and not exists (
|
||||||
select 1 from aihr_data_asset governed
|
select 1 from aihr_data_asset governed
|
||||||
where governed.tenant_id = a.tenant_id and governed.knowledge_id = a.knowledge_id
|
where governed.tenant_id = a.tenant_id and governed.knowledge_id = a.knowledge_id
|
||||||
and governed.doc_id = a.doc_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;
|
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 {
|
private static void copyBounded(InputStream input, OutputStream output, long maxBytes) throws IOException {
|
||||||
byte[] buffer = new byte[8192];
|
byte[] buffer = new byte[8192];
|
||||||
long total = 0;
|
long total = 0;
|
||||||
|
|||||||
+272
-66
@@ -42,8 +42,8 @@ public class AihrKnowledgeLifecycleService {
|
|||||||
private final AihrKnowledgeQualityGateService qualityGateService;
|
private final AihrKnowledgeQualityGateService qualityGateService;
|
||||||
private final AihrKnowledgeClaimService claimService;
|
private final AihrKnowledgeClaimService claimService;
|
||||||
|
|
||||||
@Value("${aihr.qdrant.collection:${AIHR_QDRANT_COLLECTION:aihr_knowledge}}")
|
@Value("${aihr.qdrant.governed-collection:${AIHR_QDRANT_GOVERNED_COLLECTION:aihr_knowledge_governed_v1}}")
|
||||||
private String productionCollection = "aihr_knowledge";
|
private String productionCollection = AihrKnowledgeRolloutService.DEFAULT_GOVERNED_COLLECTION;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public StagedAsset stageParsedDocument(StageCommand command) {
|
public StagedAsset stageParsedDocument(StageCommand command) {
|
||||||
@@ -154,12 +154,22 @@ public class AihrKnowledgeLifecycleService {
|
|||||||
semantic_detector_version = ? where tenant_id = ? and id = ?
|
semantic_detector_version = ? where tenant_id = ? and id = ?
|
||||||
""", AihrKnowledgeSemanticAnalysisService.DETECTOR_VERSION, command.tenantId(), versionId);
|
""", 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("""
|
jdbcTemplate.update("""
|
||||||
update aihr_data_asset
|
update aihr_data_asset
|
||||||
set current_version_id = ?, lifecycle_status = ?, index_status = 'NOT_REQUIRED',
|
set candidate_version_id = ?,
|
||||||
trust_level = 'UNTRUSTED', content_sha256 = ?, update_time = now()
|
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 = ?
|
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()
|
List<String> reasonCodes = new ArrayList<>(assessment.findings().stream()
|
||||||
.map(finding -> finding.reasonCode().name()).toList());
|
.map(finding -> finding.reasonCode().name()).toList());
|
||||||
dynamicReasons.stream().map(Enum::name).forEach(reasonCodes::add);
|
dynamicReasons.stream().map(Enum::name).forEach(reasonCodes::add);
|
||||||
@@ -191,12 +201,22 @@ public class AihrKnowledgeLifecycleService {
|
|||||||
String.valueOf(versionId), "PARSE_FAILED", command.extractionQuality().extractorName(),
|
String.valueOf(versionId), "PARSE_FAILED", command.extractionQuality().extractorName(),
|
||||||
command.extractionQuality().extractorVersion());
|
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("""
|
jdbcTemplate.update("""
|
||||||
update aihr_data_asset
|
update aihr_data_asset
|
||||||
set current_version_id = ?, lifecycle_status = 'QUARANTINED', index_status = 'NOT_REQUIRED',
|
set candidate_version_id = ?,
|
||||||
trust_level = 'UNTRUSTED', content_sha256 = ?, update_time = now()
|
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 = ?
|
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(),
|
return new StagedAsset(assetId, versionId, versionNo, Status.QUARANTINED.name(),
|
||||||
List.of(command.reasonCode().name()), 0);
|
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.source_authority, a.source_version, a.data_class, a.lifecycle_status, a.index_status,
|
||||||
a.applicable_region, a.applicable_project, a.applicable_role,
|
a.applicable_region, a.applicable_project, a.applicable_role,
|
||||||
a.effective_from, a.effective_to,
|
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,
|
v.semantic_retry_count, a.update_time,
|
||||||
(select count(*) from aihr_quality_issue q
|
(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,
|
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'
|
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
|
and q.gate_type = 'HARD') blocking_issue_count
|
||||||
from aihr_data_asset a
|
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
|
||||||
where a.tenant_id = ? and a.lifecycle_status = ?
|
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
|
order by a.update_time asc, a.id asc
|
||||||
limit ?
|
limit ?
|
||||||
""", (rs, rowNum) -> new AssetSummary(
|
""", (rs, rowNum) -> new AssetSummary(
|
||||||
@@ -235,7 +259,7 @@ public class AihrKnowledgeLifecycleService {
|
|||||||
rs.getObject("current_version_id", Long.class), rs.getString("semantic_analysis_status"),
|
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.getString("semantic_last_error"), rs.getInt("semantic_retry_count"), rs.getInt("issue_count"),
|
||||||
rs.getInt("blocking_issue_count"), rs.getObject("update_time", LocalDateTime.class)),
|
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) {
|
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,
|
select v.id, v.privacy_status, v.redaction_policy_version, v.redaction_summary_json,
|
||||||
v.redacted_source_name, v.redacted_content
|
v.redacted_source_name, v.redacted_content
|
||||||
from aihr_data_asset a
|
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 = ?
|
where a.tenant_id = ? and a.id = ?
|
||||||
limit 1
|
limit 1
|
||||||
""", (rs, rowNum) -> new PrivacyPreview(
|
""", (rs, rowNum) -> new PrivacyPreview(
|
||||||
@@ -276,15 +301,22 @@ public class AihrKnowledgeLifecycleService {
|
|||||||
}
|
}
|
||||||
AssetRow asset = lockAsset(tenantId, assetId);
|
AssetRow asset = lockAsset(tenantId, assetId);
|
||||||
Status current = Status.valueOf(asset.lifecycleStatus());
|
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);
|
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("""
|
VersionGate versionGate = jdbcTemplate.queryForObject("""
|
||||||
select semantic_analysis_status, privacy_status,
|
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
|
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 = ?
|
from aihr_data_version where tenant_id = ? and id = ?
|
||||||
""", (rs, rowNum) -> new VersionGate(rs.getString("semantic_analysis_status"),
|
""", (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()
|
if (versionGate == null || !versionGate.privacyReady()
|
||||||
|| !Set.of("CLEAN", "REDACTED").contains(versionGate.privacyStatus())) {
|
|| !Set.of("CLEAN", "REDACTED").contains(versionGate.privacyStatus())) {
|
||||||
throw new ServiceException("A current privacy derivative and residual scan are required before approval",
|
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",
|
throw new ServiceException("Semantic duplicate analysis must complete before approval",
|
||||||
HttpStatus.CONFLICT);
|
HttpStatus.CONFLICT);
|
||||||
}
|
}
|
||||||
claimService.requireResolvedConflicts(tenantId, assetId, asset.versionId());
|
claimService.requireResolvedConflicts(tenantId, assetId, versionId);
|
||||||
acceptSoftFindings(tenantId, assetId, asset.versionId(), reviewerId, command.acceptedReasonCodes());
|
acceptSoftFindings(tenantId, assetId, versionId, reviewerId, command.acceptedReasonCodes());
|
||||||
Integer hardOpen = jdbcTemplate.queryForObject("""
|
Integer hardOpen = jdbcTemplate.queryForObject("""
|
||||||
select count(*) from aihr_quality_issue
|
select count(*) from aihr_quality_issue
|
||||||
where tenant_id = ? and asset_id = ? and version_id = ? and status = 'OPEN' and gate_type = 'HARD'
|
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) {
|
if (hardOpen != null && hardOpen > 0) {
|
||||||
throw new ServiceException("Blocking quality findings must be resolved before approval", HttpStatus.CONFLICT);
|
throw new ServiceException("Blocking quality findings must be resolved before approval", HttpStatus.CONFLICT);
|
||||||
}
|
}
|
||||||
Integer softOpen = jdbcTemplate.queryForObject("""
|
Integer softOpen = jdbcTemplate.queryForObject("""
|
||||||
select count(*) from aihr_quality_issue
|
select count(*) from aihr_quality_issue
|
||||||
where tenant_id = ? and asset_id = ? and version_id = ? and status = 'OPEN' and gate_type = 'SOFT'
|
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) {
|
if (softOpen != null && softOpen > 0) {
|
||||||
throw new ServiceException("Every soft quality finding must be acknowledged before approval", HttpStatus.CONFLICT);
|
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);
|
"Normative knowledge requires an approved authority type and source version", HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
if (usage == UsageType.NORMATIVE_KNOWLEDGE
|
if (usage == UsageType.NORMATIVE_KNOWLEDGE
|
||||||
&& hasQualityReason(tenantId, assetId, asset.versionId(), ReasonCode.VERSION_CONFLICT)
|
&& hasQualityReason(tenantId, assetId, versionId, ReasonCode.VERSION_CONFLICT)
|
||||||
&& command.supersedesAssetId() == null) {
|
&& command.supersedesAssetId() == null) {
|
||||||
throw new ServiceException("A conflicting normative source must explicitly supersede the old asset",
|
throw new ServiceException("A conflicting normative source must explicitly supersede the old asset",
|
||||||
HttpStatus.CONFLICT);
|
HttpStatus.CONFLICT);
|
||||||
@@ -344,13 +376,12 @@ public class AihrKnowledgeLifecycleService {
|
|||||||
"Superseded by asset " + assetId + ": " + command.reason().trim());
|
"Superseded by asset " + assetId + ": " + command.reason().trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
removePublishedFragments(tenantId, assetId);
|
|
||||||
List<ChunkRow> chunks = jdbcTemplate.query("""
|
List<ChunkRow> chunks = jdbcTemplate.query("""
|
||||||
select id, chunk_index, content, locator_json from aihr_chunk_revision
|
select id, chunk_index, content, locator_json from aihr_chunk_revision
|
||||||
where tenant_id = ? and asset_id = ? and version_id = ? order by chunk_index
|
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, rowNum) -> new ChunkRow(rs.getLong("id"), rs.getInt("chunk_index"), rs.getString("content"),
|
||||||
rs.getString("locator_json")),
|
rs.getString("locator_json")),
|
||||||
tenantId, assetId, asset.versionId());
|
tenantId, assetId, versionId);
|
||||||
if (chunks.isEmpty()) {
|
if (chunks.isEmpty()) {
|
||||||
throw new ServiceException("Approved version has no chunks", HttpStatus.CONFLICT);
|
throw new ServiceException("Approved version has no chunks", HttpStatus.CONFLICT);
|
||||||
}
|
}
|
||||||
@@ -374,7 +405,7 @@ public class AihrKnowledgeLifecycleService {
|
|||||||
""", Statement.RETURN_GENERATED_KEYS);
|
""", Statement.RETURN_GENERATED_KEYS);
|
||||||
statement.setString(1, tenantId);
|
statement.setString(1, tenantId);
|
||||||
statement.setLong(2, assetId);
|
statement.setLong(2, assetId);
|
||||||
statement.setLong(3, asset.versionId());
|
statement.setLong(3, versionId);
|
||||||
statement.setString(4, command.reason().trim());
|
statement.setString(4, command.reason().trim());
|
||||||
statement.setString(5, json(new Snapshot(asset.lifecycleStatus(), asset.dataClass(), asset.sourceVersion())));
|
statement.setString(5, json(new Snapshot(asset.lifecycleStatus(), asset.dataClass(), asset.sourceVersion())));
|
||||||
statement.setString(6, json(new Snapshot("PUBLISHED", usage.name(), command.sourceVersion())));
|
statement.setString(6, json(new Snapshot("PUBLISHED", usage.name(), command.sourceVersion())));
|
||||||
@@ -388,15 +419,31 @@ public class AihrKnowledgeLifecycleService {
|
|||||||
values (?, ?, 'production', ?, 'ACTIVE', ?, now())
|
values (?, ?, 'production', ?, 'ACTIVE', ?, now())
|
||||||
on duplicate key update usage_type = values(usage_type), status = 'ACTIVE',
|
on duplicate key update usage_type = values(usage_type), status = 'ACTIVE',
|
||||||
approved_review_id = values(approved_review_id)
|
approved_review_id = values(approved_review_id)
|
||||||
""", tenantId, asset.versionId(), usage.name(), reviewId);
|
""", tenantId, versionId, usage.name(), reviewId);
|
||||||
enqueueIndex(tenantId, assetId, asset.versionId(), "UPSERT", asset.docId());
|
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("""
|
jdbcTemplate.update("""
|
||||||
update aihr_data_asset
|
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 = ?
|
where tenant_id = ? and id = ?
|
||||||
""", usage.name(), tenantId, assetId);
|
""", usage.name(), versionId, versionId, tenantId, assetId);
|
||||||
claimService.markPublished(tenantId, asset.versionId());
|
claimService.markPublished(tenantId, versionId);
|
||||||
return new PublishedAsset(assetId, asset.versionId(), "PUBLISHED", "PENDING", chunks.size(), reviewId);
|
return new PublishedAsset(assetId, versionId, "PUBLISHED", "PENDING", chunks.size(), reviewId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -408,13 +455,28 @@ public class AihrKnowledgeLifecycleService {
|
|||||||
if (!AihrKnowledgeLifecycle.canWithdraw(Status.valueOf(asset.lifecycleStatus()))) {
|
if (!AihrKnowledgeLifecycle.canWithdraw(Status.valueOf(asset.lifecycleStatus()))) {
|
||||||
throw new ServiceException("Only approved or published assets can be withdrawn", HttpStatus.CONFLICT);
|
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 = ?",
|
jdbcTemplate.update("update aihr_dataset_membership set status = 'DISABLED' where tenant_id = ? and version_id = ?",
|
||||||
tenantId, asset.versionId());
|
tenantId, versionId);
|
||||||
enqueueIndex(tenantId, assetId, asset.versionId(), "DELETE", asset.docId());
|
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("""
|
jdbcTemplate.update("""
|
||||||
update aihr_data_asset
|
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 = ?
|
where tenant_id = ? and id = ?
|
||||||
""", tenantId, assetId);
|
""", tenantId, assetId);
|
||||||
claimService.markDeprecated(tenantId, assetId);
|
claimService.markDeprecated(tenantId, assetId);
|
||||||
@@ -422,8 +484,8 @@ public class AihrKnowledgeLifecycleService {
|
|||||||
insert into aihr_review_decision
|
insert into aihr_review_decision
|
||||||
(tenant_id, asset_id, version_id, decision, reason, reviewer_id, reviewer_type, reviewed_time)
|
(tenant_id, asset_id, version_id, decision, reason, reviewer_id, reviewer_type, reviewed_time)
|
||||||
values (?, ?, ?, 'WITHDRAW', ?, ?, 'HUMAN', now())
|
values (?, ?, ?, 'WITHDRAW', ?, ?, 'HUMAN', now())
|
||||||
""", tenantId, assetId, asset.versionId(), reason.trim(), reviewerId);
|
""", tenantId, assetId, versionId, reason.trim(), reviewerId);
|
||||||
return new PublishedAsset(assetId, asset.versionId(), "DEPRECATED", "PENDING", fragments, null);
|
return new PublishedAsset(assetId, versionId, "DEPRECATED", "PENDING", fragments == null ? 0 : fragments, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private long upsertAsset(StageCommand command, String contentHash) {
|
private long upsertAsset(StageCommand command, String contentHash) {
|
||||||
@@ -650,7 +712,9 @@ public class AihrKnowledgeLifecycleService {
|
|||||||
List<DuplicateMatch> rows = jdbcTemplate.query("""
|
List<DuplicateMatch> rows = jdbcTemplate.query("""
|
||||||
select a.id asset_id, v.id version_id
|
select a.id asset_id, v.id version_id
|
||||||
from aihr_data_asset a
|
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 = ?
|
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
|
order by case a.lifecycle_status when 'PUBLISHED' then 0 else 1 end, a.update_time desc
|
||||||
limit 1
|
limit 1
|
||||||
@@ -711,7 +775,9 @@ public class AihrKnowledgeLifecycleService {
|
|||||||
return jdbcTemplate.query("""
|
return jdbcTemplate.query("""
|
||||||
select a.id, v.id version_id, v.simhash64
|
select a.id, v.id version_id, v.simhash64
|
||||||
from aihr_data_asset a
|
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
|
where a.tenant_id = ? and a.id <> ? and v.simhash64 is not null
|
||||||
order by a.update_time desc
|
order by a.update_time desc
|
||||||
limit 2000
|
limit 2000
|
||||||
@@ -756,13 +822,16 @@ public class AihrKnowledgeLifecycleService {
|
|||||||
throw new ServiceException("Invalid superseded asset", HttpStatus.BAD_REQUEST);
|
throw new ServiceException("Invalid superseded asset", HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
List<AssetRow> rows = jdbcTemplate.query("""
|
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
|
source_version, source_name
|
||||||
from aihr_data_asset
|
from aihr_data_asset
|
||||||
where tenant_id = ? and id = ? for update
|
where tenant_id = ? and id = ? for update
|
||||||
""", (rs, rowNum) -> new AssetRow(rs.getLong("id"), rs.getLong("knowledge_id"), rs.getString("doc_id"),
|
""", (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.getLong("current_version_id"), rs.getObject("published_version_id", Long.class),
|
||||||
rs.getString("source_version"), rs.getString("source_name")), tenantId, supersededAssetId);
|
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())) {
|
if (rows.isEmpty() || !Set.of("PUBLISHED", "APPROVED").contains(rows.get(0).lifecycleStatus())) {
|
||||||
throw new ServiceException("Superseded asset is not currently published", HttpStatus.CONFLICT);
|
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) {
|
private AssetRow lockAsset(String tenantId, long assetId) {
|
||||||
List<AssetRow> rows = jdbcTemplate.query("""
|
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
|
source_name
|
||||||
from aihr_data_asset where tenant_id = ? and id = ? for update
|
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, 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.getLong("current_version_id"), rs.getObject("published_version_id", Long.class),
|
||||||
rs.getString("source_version"), rs.getString("source_name")), tenantId, assetId);
|
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()) {
|
if (rows.isEmpty()) {
|
||||||
throw new ServiceException("Data asset not found", HttpStatus.NOT_FOUND);
|
throw new ServiceException("Data asset not found", HttpStatus.NOT_FOUND);
|
||||||
}
|
}
|
||||||
return rows.get(0);
|
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,
|
private void acceptSoftFindings(String tenantId, long assetId, long versionId, long reviewerId,
|
||||||
List<String> reasonCodes) {
|
List<String> reasonCodes) {
|
||||||
Set<String> accepted = new LinkedHashSet<>(reasonCodes == null ? List.of() : reasonCodes);
|
Set<String> accepted = new LinkedHashSet<>(reasonCodes == null ? List.of() : reasonCodes);
|
||||||
@@ -844,22 +1010,6 @@ public class AihrKnowledgeLifecycleService {
|
|||||||
command.supersedesAssetId(), tenantId, assetId);
|
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,
|
private long insertPublishedFragment(String tenantId, long knowledgeId, String docId, long reviewerId,
|
||||||
ChunkRow chunk) {
|
ChunkRow chunk) {
|
||||||
KeyHolder keys = new GeneratedKeyHolder();
|
KeyHolder keys = new GeneratedKeyHolder();
|
||||||
@@ -929,12 +1079,26 @@ public class AihrKnowledgeLifecycleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void enqueueIndex(String tenantId, long assetId, long versionId, String operation, String docId) {
|
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("""
|
jdbcTemplate.update("""
|
||||||
insert into aihr_index_outbox
|
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)
|
attempt_count, next_attempt_time, create_time, update_time)
|
||||||
values (?, ?, ?, ?, ?, ?, 'PENDING', 0, now(), now(), now())
|
values (?, ?, ?, ?, ?, ?, ?, 'PENDING', 0, now(), now(), now())
|
||||||
""", tenantId, assetId, versionId, operation, productionCollection, json(new IndexPayload(docId)));
|
""", 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,
|
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 {
|
try {
|
||||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||||
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||||
@@ -1056,9 +1220,16 @@ public class AihrKnowledgeLifecycleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record ReviewCommand(String reason, String usageType, String sourceAuthority, String sourceVersion,
|
public record ReviewCommand(String reason, String usageType, String sourceAuthority, String sourceVersion,
|
||||||
|
String applicableRegion, String applicableProject, String applicableRole,
|
||||||
|
LocalDate effectiveFrom, LocalDate effectiveTo, List<String> acceptedReasonCodes,
|
||||||
|
Long supersedesAssetId, Long expectedVersionId) {
|
||||||
|
public ReviewCommand(String reason, String usageType, String sourceAuthority, String sourceVersion,
|
||||||
String applicableRegion, String applicableProject, String applicableRole,
|
String applicableRegion, String applicableProject, String applicableRole,
|
||||||
LocalDate effectiveFrom, LocalDate effectiveTo, List<String> acceptedReasonCodes,
|
LocalDate effectiveFrom, LocalDate effectiveTo, List<String> acceptedReasonCodes,
|
||||||
Long supersedesAssetId) {
|
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,
|
public record PublishedAsset(long assetId, long versionId, String lifecycleStatus, String indexStatus,
|
||||||
@@ -1084,8 +1255,40 @@ public class AihrKnowledgeLifecycleService {
|
|||||||
String redactedContentPreview) {
|
String redactedContentPreview) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private record AssetRow(long id, long knowledgeId, String docId, long versionId, String lifecycleStatus,
|
public record VersionSummary(long id, int versionNo, String versionStatus, Long revisionOfVersionId,
|
||||||
String dataClass, String sourceVersion, String sourceName) {
|
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) {
|
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 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) {
|
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 AihrKnowledgeRuleSamplingService ruleSamplingService;
|
||||||
private final AihrKnowledgePipelineRunService pipelineRunService;
|
private final AihrKnowledgePipelineRunService pipelineRunService;
|
||||||
private final AihrKnowledgeGoldenDatasetService goldenDatasetService;
|
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")
|
@GetMapping("/assets")
|
||||||
public R<List<AihrKnowledgeLifecycleService.AssetSummary>> assets(
|
public R<List<AihrKnowledgeLifecycleService.AssetSummary>> assets(
|
||||||
@@ -53,11 +71,157 @@ public class AihrKnowledgeQualityController {
|
|||||||
return R.ok(lifecycleService.privacyPreview(tenantId(), assetId));
|
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")
|
@GetMapping("/assets/{assetId}/conflicts")
|
||||||
public R<List<AihrKnowledgeClaimService.ConflictView>> conflicts(@PathVariable long assetId) {
|
public R<List<AihrKnowledgeClaimService.ConflictView>> conflicts(@PathVariable long assetId) {
|
||||||
return R.ok(claimService.conflicts(tenantId(), 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")
|
@PostMapping("/conflicts/{conflictId}/review")
|
||||||
public R<AihrKnowledgeClaimService.ConflictReviewResult> reviewConflict(
|
public R<AihrKnowledgeClaimService.ConflictReviewResult> reviewConflict(
|
||||||
@PathVariable long conflictId, @RequestBody ConflictReviewRequest request) {
|
@PathVariable long conflictId, @RequestBody ConflictReviewRequest request) {
|
||||||
|
|||||||
+11
-6
@@ -54,19 +54,21 @@ public class AihrKnowledgeQualityMonitoringService {
|
|||||||
sum(case when lifecycle_status = 'PUBLISHED' and (
|
sum(case when lifecycle_status = 'PUBLISHED' and (
|
||||||
source_authority is null or source_authority = '' or source_authority = 'UNKNOWN'
|
source_authority is null or source_authority = '' or source_authority = 'UNKNOWN'
|
||||||
or source_version is null or trim(source_version) = ''
|
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 (
|
or not exists (
|
||||||
select 1 from aihr_review_decision review
|
select 1 from aihr_review_decision review
|
||||||
where review.tenant_id = aihr_data_asset.tenant_id
|
where review.tenant_id = aihr_data_asset.tenant_id
|
||||||
and review.asset_id = aihr_data_asset.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'
|
and review.decision = 'APPROVE' and review.reviewer_type = 'HUMAN'
|
||||||
)
|
)
|
||||||
or not exists (
|
or not exists (
|
||||||
select 1 from aihr_chunk_revision chunk
|
select 1 from aihr_chunk_revision chunk
|
||||||
where chunk.tenant_id = aihr_data_asset.tenant_id
|
where chunk.tenant_id = aihr_data_asset.tenant_id
|
||||||
and chunk.asset_id = aihr_data_asset.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
|
and chunk.published_fragment_id is not null
|
||||||
)
|
)
|
||||||
) then 1 else 0 end) untraceable_published,
|
) then 1 else 0 end) untraceable_published,
|
||||||
@@ -74,20 +76,23 @@ public class AihrKnowledgeQualityMonitoringService {
|
|||||||
select 1 from aihr_quality_issue issue_row
|
select 1 from aihr_quality_issue issue_row
|
||||||
where issue_row.tenant_id = aihr_data_asset.tenant_id
|
where issue_row.tenant_id = aihr_data_asset.tenant_id
|
||||||
and issue_row.asset_id = aihr_data_asset.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'
|
and issue_row.status = 'OPEN'
|
||||||
) then 1 else 0 end) published_open_findings,
|
) then 1 else 0 end) published_open_findings,
|
||||||
sum(case when lifecycle_status = 'PUBLISHED' and not exists (
|
sum(case when lifecycle_status = 'PUBLISHED' and not exists (
|
||||||
select 1 from aihr_dataset_membership member
|
select 1 from aihr_dataset_membership member
|
||||||
where member.tenant_id = aihr_data_asset.tenant_id
|
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'
|
and member.dataset_code = ? and member.status = 'ACTIVE'
|
||||||
) then 1 else 0 end) published_membership_gap,
|
) then 1 else 0 end) published_membership_gap,
|
||||||
sum(case when lifecycle_status = 'PUBLISHED' and exists (
|
sum(case when lifecycle_status = 'PUBLISHED' and exists (
|
||||||
select 1 from aihr_chunk_revision chunk
|
select 1 from aihr_chunk_revision chunk
|
||||||
where chunk.tenant_id = aihr_data_asset.tenant_id
|
where chunk.tenant_id = aihr_data_asset.tenant_id
|
||||||
and chunk.asset_id = aihr_data_asset.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
|
and chunk.published_fragment_id is null
|
||||||
) then 1 else 0 end) published_fragment_gap
|
) then 1 else 0 end) published_fragment_gap
|
||||||
from aihr_data_asset
|
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("""
|
List<PendingVersion> versions = jdbcTemplate.query("""
|
||||||
select v.id, v.tenant_id, v.asset_id, v.redacted_content, v.semantic_retry_count
|
select v.id, v.tenant_id, v.asset_id, v.redacted_content, v.semantic_retry_count
|
||||||
from aihr_data_version v
|
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')
|
where v.semantic_analysis_status in ('PENDING','FAILED')
|
||||||
and v.privacy_status in ('CLEAN','REDACTED')
|
and v.privacy_status in ('CLEAN','REDACTED')
|
||||||
and v.redacted_content is not null
|
and v.redacted_content is not null
|
||||||
@@ -199,7 +200,8 @@ public class AihrKnowledgeSemanticAnalysisService {
|
|||||||
List<SemanticCandidate> candidates = jdbcTemplate.query("""
|
List<SemanticCandidate> candidates = jdbcTemplate.query("""
|
||||||
select a.id asset_id, v.id version_id, v.semantic_embedding_json
|
select a.id asset_id, v.id version_id, v.semantic_embedding_json
|
||||||
from aihr_data_asset a
|
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 <> ?
|
where a.tenant_id = ? and a.id <> ?
|
||||||
and a.lifecycle_status in ('REVIEW_PENDING','APPROVED','PUBLISHED')
|
and a.lifecycle_status in ('REVIEW_PENDING','APPROVED','PUBLISHED')
|
||||||
and v.semantic_analysis_status = 'COMPLETE'
|
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.AihrKnowledgeQueryDto.LocatorSummary;
|
||||||
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
|
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
|
||||||
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.TextSegment;
|
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.GlobalConstants;
|
||||||
import org.dromara.common.core.constant.HttpStatus;
|
import org.dromara.common.core.constant.HttpStatus;
|
||||||
import org.dromara.common.core.exception.ServiceException;
|
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
|
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
|
and l.attachment_id = a.id
|
||||||
where f.tenant_id = ? and f.id = ? and f.knowledge_id in (%s) and k.status = 'ACTIVE'
|
where f.tenant_id = ? and f.id = ? and f.knowledge_id in (%s) and k.status = 'ACTIVE'
|
||||||
and exists (
|
and %s
|
||||||
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 not exists (
|
and not exists (
|
||||||
select 1
|
select 1
|
||||||
from aihr_knowledge_source_governance invalid_governance
|
from aihr_knowledge_source_governance invalid_governance
|
||||||
@@ -109,7 +92,8 @@ public class AihrKnowledgeCitationDetailService {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
order by a.id desc limit 1
|
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, 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.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"),
|
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("""
|
List<TextSegment> rows = jdbcTemplate.query("""
|
||||||
select f.id, f.idx, f.content from aihr_knowledge_fragment f
|
select f.id, f.idx, f.content from aihr_knowledge_fragment f
|
||||||
where f.tenant_id = ? and f.knowledge_id = ? and f.doc_id = ?
|
where f.tenant_id = ? and f.knowledge_id = ? and f.doc_id = ?
|
||||||
and exists (
|
and %s
|
||||||
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())
|
|
||||||
)
|
|
||||||
order by f.idx, f.id limit 200
|
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);
|
rs.getLong("id") == targetId), tenantId, knowledgeId, docId);
|
||||||
int chars = 0;
|
int chars = 0;
|
||||||
List<TextSegment> bounded = new ArrayList<>();
|
List<TextSegment> bounded = new ArrayList<>();
|
||||||
|
|||||||
+2
-1
@@ -86,7 +86,8 @@ public class AihrKnowledgeQueryAuditService {
|
|||||||
from aihr_chunk_revision c
|
from aihr_chunk_revision c
|
||||||
join aihr_data_asset a on a.tenant_id = c.tenant_id and a.id = c.asset_id
|
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 = ?
|
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
|
limit 1
|
||||||
""", tenantId, requestId, rank, fragmentId,
|
""", tenantId, requestId, rank, fragmentId,
|
||||||
normalizedChannel(snippet.retrievalChannel()), snippet.retrievalScore(),
|
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.BroadcastContext;
|
||||||
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
|
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
|
||||||
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse;
|
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.domain.AihrKnowledgeQueryDto.Resource;
|
||||||
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.ConversationContext;
|
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.ConversationContext;
|
||||||
import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.ToolResult;
|
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
|
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
|
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 a.knowledge_id in (%s) and k.status = 'ACTIVE'
|
||||||
and exists (
|
and %s
|
||||||
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())
|
|
||||||
)
|
|
||||||
limit 1
|
limit 1
|
||||||
""".formatted(placeholders(spaceIds.size())), Long.class, args.toArray());
|
""".formatted(placeholders(spaceIds.size()),
|
||||||
|
AihrKnowledgeRolloutService.servingAttachmentExistsSql("a")), Long.class, args.toArray());
|
||||||
}
|
}
|
||||||
if (!rows.isEmpty()) {
|
if (!rows.isEmpty()) {
|
||||||
return rows.get(0);
|
return rows.get(0);
|
||||||
@@ -762,7 +748,7 @@ public class AihrKnowledgeQueryService {
|
|||||||
join aihr_data_asset governed_title_asset
|
join aihr_data_asset governed_title_asset
|
||||||
on governed_title_asset.tenant_id = governed_title_chunk.tenant_id
|
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.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
|
join aihr_data_version governed_version
|
||||||
on governed_version.tenant_id = governed_title_chunk.tenant_id
|
on governed_version.tenant_id = governed_title_chunk.tenant_id
|
||||||
and governed_version.id = governed_title_chunk.version_id
|
and governed_version.id = governed_title_chunk.version_id
|
||||||
@@ -784,7 +770,7 @@ public class AihrKnowledgeQueryService {
|
|||||||
join aihr_data_asset governed_asset
|
join aihr_data_asset governed_asset
|
||||||
on governed_asset.tenant_id = governed_chunk.tenant_id
|
on governed_asset.tenant_id = governed_chunk.tenant_id
|
||||||
and governed_asset.id = governed_chunk.asset_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
|
join aihr_dataset_membership governed_dataset
|
||||||
on governed_dataset.tenant_id = governed_chunk.tenant_id
|
on governed_dataset.tenant_id = governed_chunk.tenant_id
|
||||||
and governed_dataset.version_id = governed_chunk.version_id
|
and governed_dataset.version_id = governed_chunk.version_id
|
||||||
|
|||||||
+4
-2
@@ -706,7 +706,8 @@ public class AihrExamService {
|
|||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1 FROM aihr_chunk_revision c
|
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
|
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'
|
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
|
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'
|
AND d.dataset_code = 'production' AND d.status = 'ACTIVE'
|
||||||
@@ -729,7 +730,8 @@ public class AihrExamService {
|
|||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1 FROM aihr_chunk_revision c
|
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
|
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'
|
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
|
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'
|
AND d.dataset_code = 'production' AND d.status = 'ACTIVE'
|
||||||
|
|||||||
+259
-30
@@ -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.UsageType;
|
||||||
import org.dromara.aihr.knowledge.quality.AihrKnowledgeLifecycle.ReasonCode;
|
import org.dromara.aihr.knowledge.quality.AihrKnowledgeLifecycle.ReasonCode;
|
||||||
import org.dromara.aihr.knowledge.quality.AihrKnowledgeLifecycleService;
|
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.AihrKnowledgePrivacyService;
|
||||||
import org.dromara.aihr.knowledge.quality.AihrKnowledgeTextProcessor;
|
import org.dromara.aihr.knowledge.quality.AihrKnowledgeTextProcessor;
|
||||||
import org.dromara.aihr.personal.service.EnterpriseKnowledgeAccessPolicy.EnterpriseKnowledgeGrant;
|
import org.dromara.aihr.personal.service.EnterpriseKnowledgeAccessPolicy.EnterpriseKnowledgeGrant;
|
||||||
@@ -286,7 +287,7 @@ public class AihrSopSeedService {
|
|||||||
and match(f.content) against (? in natural language mode)
|
and match(f.content) against (? in natural language mode)
|
||||||
order by score desc, f.id asc
|
order by score desc, f.id asc
|
||||||
limit ?
|
limit ?
|
||||||
""".formatted(placeholders, publishedLifecycleScopeSql("f"));
|
""".formatted(placeholders, servingLifecycleScopeSql("f"));
|
||||||
List<Object> args = new ArrayList<>();
|
List<Object> args = new ArrayList<>();
|
||||||
args.add(query);
|
args.add(query);
|
||||||
args.add(grant.tenantId());
|
args.add(grant.tenantId());
|
||||||
@@ -706,7 +707,7 @@ public class AihrSopSeedService {
|
|||||||
and f.id in (%s)
|
and f.id in (%s)
|
||||||
%s
|
%s
|
||||||
%s
|
%s
|
||||||
""".formatted(placeholders, knowledgeScopeSql("f", allowedKnowledgeIds), publishedLifecycleScopeSql("f"));
|
""".formatted(placeholders, knowledgeScopeSql("f", allowedKnowledgeIds), servingLifecycleScopeSql("f"));
|
||||||
List<Object> args = new ArrayList<>();
|
List<Object> args = new ArrayList<>();
|
||||||
args.add(tenantId());
|
args.add(tenantId());
|
||||||
args.addAll(fragmentIds);
|
args.addAll(fragmentIds);
|
||||||
@@ -872,6 +873,17 @@ public class AihrSopSeedService {
|
|||||||
*/
|
*/
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public UploadResponse reprocessStagedAttachment(Long attachmentId, Path stagedFile) {
|
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)) {
|
if (attachmentId == null || attachmentId <= 0 || stagedFile == null || !Files.isRegularFile(stagedFile)) {
|
||||||
throw new ServiceException("MEDIA_RETRY_SOURCE_MISSING: 待重试资料不存在");
|
throw new ServiceException("MEDIA_RETRY_SOURCE_MISSING: 待重试资料不存在");
|
||||||
}
|
}
|
||||||
@@ -897,7 +909,7 @@ public class AihrSopSeedService {
|
|||||||
|
|
||||||
String docId = isBlank(attachment.docId())
|
String docId = isBlank(attachment.docId())
|
||||||
? UUID.randomUUID().toString().replace("-", "") : attachment.docId();
|
? UUID.randomUUID().toString().replace("-", "") : attachment.docId();
|
||||||
if (!docId.equals(attachment.docId())) {
|
if (updateLegacyAttachment && !docId.equals(attachment.docId())) {
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
update aihr_knowledge_attach set doc_id = ?, update_time = now()
|
update aihr_knowledge_attach set doc_id = ?, update_time = now()
|
||||||
where tenant_id = ? and id = ?
|
where tenant_id = ? and id = ?
|
||||||
@@ -912,8 +924,10 @@ public class AihrSopSeedService {
|
|||||||
AihrKnowledgeLifecycleService.StagedAsset staged = stageRawFailureCandidate(
|
AihrKnowledgeLifecycleService.StagedAsset staged = stageRawFailureCandidate(
|
||||||
attachment.knowledgeId(), docId, attachment.ossId(), attachment.fileName(), sourceFingerprint,
|
attachment.knowledgeId(), docId, attachment.ossId(), attachment.fileName(), sourceFingerprint,
|
||||||
reasonCode, safeExtractionEvidence(error), AihrExtractionQuality.none());
|
reasonCode, safeExtractionEvidence(error), AihrExtractionQuality.none());
|
||||||
|
if (updateLegacyAttachment) {
|
||||||
markAttachStatus(attachment.knowledgeId(), docId, 3,
|
markAttachStatus(attachment.knowledgeId(), docId, 3,
|
||||||
"quality-quarantined:asset=" + staged.assetId() + ":reason=" + reasonCode.name());
|
"quality-quarantined:asset=" + staged.assetId() + ":reason=" + reasonCode.name());
|
||||||
|
}
|
||||||
return quarantinedUploadResponse(docId, attachment.ossId(), attachment.fileName(),
|
return quarantinedUploadResponse(docId, attachment.ossId(), attachment.fileName(),
|
||||||
attachment.spaceName(), reasonCode);
|
attachment.spaceName(), reasonCode);
|
||||||
}
|
}
|
||||||
@@ -924,8 +938,10 @@ public class AihrSopSeedService {
|
|||||||
AihrKnowledgeLifecycleService.StagedAsset staged = stageRawFailureCandidate(
|
AihrKnowledgeLifecycleService.StagedAsset staged = stageRawFailureCandidate(
|
||||||
attachment.knowledgeId(), docId, attachment.ossId(), attachment.fileName(), sourceFingerprint,
|
attachment.knowledgeId(), docId, attachment.ossId(), attachment.fileName(), sourceFingerprint,
|
||||||
reasonCode, "No usable text was extracted during reprocessing", extracted.quality());
|
reasonCode, "No usable text was extracted during reprocessing", extracted.quality());
|
||||||
|
if (updateLegacyAttachment) {
|
||||||
markAttachStatus(attachment.knowledgeId(), docId, 3,
|
markAttachStatus(attachment.knowledgeId(), docId, 3,
|
||||||
"quality-quarantined:asset=" + staged.assetId() + ":reason=" + reasonCode.name());
|
"quality-quarantined:asset=" + staged.assetId() + ":reason=" + reasonCode.name());
|
||||||
|
}
|
||||||
return quarantinedUploadResponse(docId, attachment.ossId(), attachment.fileName(),
|
return quarantinedUploadResponse(docId, attachment.ossId(), attachment.fileName(),
|
||||||
attachment.spaceName(), reasonCode);
|
attachment.spaceName(), reasonCode);
|
||||||
}
|
}
|
||||||
@@ -939,9 +955,11 @@ public class AihrSopSeedService {
|
|||||||
AihrKnowledgeLifecycleService.StagedAsset staged = stageCandidate(
|
AihrKnowledgeLifecycleService.StagedAsset staged = stageCandidate(
|
||||||
attachment.knowledgeId(), docId, attachment.ossId(), attachment.fileName(), content, fragments,
|
attachment.knowledgeId(), docId, attachment.ossId(), attachment.fileName(), content, fragments,
|
||||||
fingerprint, extracted.quality(), chunkPlan.locators());
|
fingerprint, extracted.quality(), chunkPlan.locators());
|
||||||
|
if (updateLegacyAttachment) {
|
||||||
updateOssInsight(attachment.ossId(), insight, fingerprint);
|
updateOssInsight(attachment.ossId(), insight, fingerprint);
|
||||||
markAttachStatus(attachment.knowledgeId(), docId, 2,
|
markAttachStatus(attachment.knowledgeId(), docId, 2,
|
||||||
"review-pending:asset=" + staged.assetId());
|
"review-pending:asset=" + staged.assetId());
|
||||||
|
}
|
||||||
|
|
||||||
List<SnippetResponse> snippets = fragments.stream()
|
List<SnippetResponse> snippets = fragments.stream()
|
||||||
.limit(3)
|
.limit(3)
|
||||||
@@ -1133,7 +1151,7 @@ public class AihrSopSeedService {
|
|||||||
return new VectorIndexStatusResponse(
|
return new VectorIndexStatusResponse(
|
||||||
runtime.modelName(),
|
runtime.modelName(),
|
||||||
expectedDimension,
|
expectedDimension,
|
||||||
qdrantCollection(),
|
governedQdrantCollection(),
|
||||||
qdrant.dimension(),
|
qdrant.dimension(),
|
||||||
qdrant.points(),
|
qdrant.points(),
|
||||||
db.fragments(),
|
db.fragments(),
|
||||||
@@ -2220,7 +2238,8 @@ public class AihrSopSeedService {
|
|||||||
and a.index_status = 'READY' then a.id end) indexed_assets
|
and a.index_status = 'READY' then a.id end) indexed_assets
|
||||||
from aihr_data_asset a
|
from aihr_data_asset a
|
||||||
left join aihr_chunk_revision c on c.tenant_id = a.tenant_id
|
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
|
left join aihr_knowledge_fragment f on f.tenant_id = c.tenant_id
|
||||||
and f.id = c.published_fragment_id
|
and f.id = c.published_fragment_id
|
||||||
where a.tenant_id = ?
|
where a.tenant_id = ?
|
||||||
@@ -2632,16 +2651,26 @@ public class AihrSopSeedService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public int vectorizePublishedDocument(long knowledgeId, String docId) {
|
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("""
|
List<DocumentVectorRow> rows = jdbcTemplate.query("""
|
||||||
select i.name, f.content
|
select i.name, f.content
|
||||||
from aihr_knowledge_fragment f
|
from aihr_knowledge_fragment f
|
||||||
join aihr_knowledge_info i on i.tenant_id = f.tenant_id and i.id = f.knowledge_id
|
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 = ?
|
where f.tenant_id = ? and f.knowledge_id = ? and f.doc_id = ?
|
||||||
%s
|
%s
|
||||||
|
and (? is null or a.published_version_id = ?)
|
||||||
order by f.idx
|
order by f.idx
|
||||||
""".formatted(publishedLifecycleScopeSql("f")),
|
""".formatted(publishedLifecycleScopeSql("f")),
|
||||||
(rs, rowNum) -> new DocumentVectorRow(rs.getString(1), rs.getString(2)),
|
(rs, rowNum) -> new DocumentVectorRow(rs.getString(1), rs.getString(2)),
|
||||||
tenantId(), knowledgeId, docId);
|
tenantId(), knowledgeId, docId, expectedVersionId, expectedVersionId);
|
||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
throw new ServiceException("Published document has no authorized fragments", 409);
|
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);
|
data.embeddings().get(index), data.modelName(), tenantId(), knowledgeId, docId, index + 1);
|
||||||
}
|
}
|
||||||
try {
|
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) {
|
} catch (Exception ex) {
|
||||||
throw new ServiceException("Vector index upsert failed").setDetailMessage(ex.getMessage());
|
throw new ServiceException("Vector index upsert failed").setDetailMessage(ex.getMessage());
|
||||||
}
|
}
|
||||||
@@ -2668,11 +2698,18 @@ public class AihrSopSeedService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void deleteVectorDocument(long knowledgeId, String docId) {
|
public void deleteVectorDocument(long knowledgeId, String docId) {
|
||||||
|
deleteVectorDocument(knowledgeId, docId, governedQdrantCollection(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteVectorDocument(long knowledgeId, String docId, String targetCollection, Long generation) {
|
||||||
try {
|
try {
|
||||||
ObjectNode body = objectMapper.createObjectNode();
|
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(
|
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())) {
|
if (response.statusCode() != 404 && !ok(response.statusCode())) {
|
||||||
throw new IllegalStateException("qdrant delete HTTP " + response.statusCode());
|
throw new IllegalStateException("qdrant delete HTTP " + response.statusCode());
|
||||||
}
|
}
|
||||||
@@ -3520,6 +3557,10 @@ public class AihrSopSeedService {
|
|||||||
return "and " + publishedLifecycleExistsSql(fragmentAlias);
|
return "and " + publishedLifecycleExistsSql(fragmentAlias);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String servingLifecycleScopeSql(String fragmentAlias) {
|
||||||
|
return "and " + AihrKnowledgeRolloutService.servingFragmentExistsSql(fragmentAlias);
|
||||||
|
}
|
||||||
|
|
||||||
private static String publishedLifecycleExistsSql(String fragmentAlias) {
|
private static String publishedLifecycleExistsSql(String fragmentAlias) {
|
||||||
return """
|
return """
|
||||||
exists (
|
exists (
|
||||||
@@ -3528,7 +3569,7 @@ public class AihrSopSeedService {
|
|||||||
join aihr_data_asset governed_asset
|
join aihr_data_asset governed_asset
|
||||||
on governed_asset.tenant_id = governed_chunk.tenant_id
|
on governed_asset.tenant_id = governed_chunk.tenant_id
|
||||||
and governed_asset.id = governed_chunk.asset_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
|
join aihr_dataset_membership governed_dataset
|
||||||
on governed_dataset.tenant_id = governed_chunk.tenant_id
|
on governed_dataset.tenant_id = governed_chunk.tenant_id
|
||||||
and governed_dataset.version_id = governed_chunk.version_id
|
and governed_dataset.version_id = governed_chunk.version_id
|
||||||
@@ -3644,7 +3685,7 @@ public class AihrSopSeedService {
|
|||||||
order by score desc, f.idx asc
|
order by score desc, f.idx asc
|
||||||
limit ?
|
limit ?
|
||||||
""".formatted(knowledgeScopeSql("f", allowedKnowledgeIds),
|
""".formatted(knowledgeScopeSql("f", allowedKnowledgeIds),
|
||||||
formalSourceScopeSql("f", formalPolicyOnly), publishedLifecycleScopeSql("f"));
|
formalSourceScopeSql("f", formalPolicyOnly), servingLifecycleScopeSql("f"));
|
||||||
List<Object> args = new ArrayList<>();
|
List<Object> args = new ArrayList<>();
|
||||||
args.add(queryText);
|
args.add(queryText);
|
||||||
args.add(tenantId());
|
args.add(tenantId());
|
||||||
@@ -3707,7 +3748,7 @@ public class AihrSopSeedService {
|
|||||||
order by score desc, f.idx asc
|
order by score desc, f.idx asc
|
||||||
limit ?
|
limit ?
|
||||||
""".formatted(String.join(" + ", scoreParts), knowledgeScopeSql("f", allowedKnowledgeIds),
|
""".formatted(String.join(" + ", scoreParts), knowledgeScopeSql("f", allowedKnowledgeIds),
|
||||||
formalSourceScopeSql("f", formalPolicyOnly), publishedLifecycleScopeSql("f"),
|
formalSourceScopeSql("f", formalPolicyOnly), servingLifecycleScopeSql("f"),
|
||||||
String.join(" or ", whereParts));
|
String.join(" or ", whereParts));
|
||||||
List<Object> args = new ArrayList<>();
|
List<Object> args = new ArrayList<>();
|
||||||
terms.stream().map(AihrSopSeedService::likePattern).forEach(args::add);
|
terms.stream().map(AihrSopSeedService::likePattern).forEach(args::add);
|
||||||
@@ -3755,14 +3796,16 @@ public class AihrSopSeedService {
|
|||||||
%s
|
%s
|
||||||
%s
|
%s
|
||||||
%s
|
%s
|
||||||
|
and f.knowledge_id = ?
|
||||||
and f.doc_id = ?
|
and f.doc_id = ?
|
||||||
and f.idx = ?
|
and f.idx = ?
|
||||||
limit 1
|
limit 1
|
||||||
""".formatted(knowledgeScopeSql("f", allowedKnowledgeIds),
|
""".formatted(knowledgeScopeSql("f", allowedKnowledgeIds),
|
||||||
formalSourceScopeSql("f", formalPolicyOnly), publishedLifecycleScopeSql("f"));
|
formalSourceScopeSql("f", formalPolicyOnly), servingLifecycleScopeSql("f"));
|
||||||
List<Object> args = new ArrayList<>();
|
List<Object> args = new ArrayList<>();
|
||||||
args.add(tenantId());
|
args.add(tenantId());
|
||||||
addKnowledgeScopeArgs(args, allowedKnowledgeIds);
|
addKnowledgeScopeArgs(args, allowedKnowledgeIds);
|
||||||
|
args.add(match.knowledgeId());
|
||||||
args.add(match.docId());
|
args.add(match.docId());
|
||||||
args.add(match.idx());
|
args.add(match.idx());
|
||||||
List<KnowledgeHit> rows = jdbcTemplate.query(sql, (rs, rowNum) -> new KnowledgeHit(
|
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 {
|
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()) {
|
if (fragments.isEmpty() || embeddings.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4293,9 +4344,41 @@ public class AihrSopSeedService {
|
|||||||
if (!firstVector.isArray() || firstVector.size() == 0) {
|
if (!firstVector.isArray() || firstVector.size() == 0) {
|
||||||
return;
|
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();
|
ArrayNode points = objectMapper.createArrayNode();
|
||||||
|
Map<Long, String> pointIds = new LinkedHashMap<>();
|
||||||
int size = Math.min(fragments.size(), embeddings.size());
|
int size = Math.min(fragments.size(), embeddings.size());
|
||||||
for (int i = 0; i < size; i++) {
|
for (int i = 0; i < size; i++) {
|
||||||
JsonNode vector = objectMapper.readTree(embeddings.get(i));
|
JsonNode vector = objectMapper.readTree(embeddings.get(i));
|
||||||
@@ -4312,9 +4395,29 @@ public class AihrSopSeedService {
|
|||||||
payload.put("dataset_code", "production");
|
payload.put("dataset_code", "production");
|
||||||
payload.put("lifecycle_status", "PUBLISHED");
|
payload.put("lifecycle_status", "PUBLISHED");
|
||||||
payload.put("trust_level", "HUMAN_VERIFIED");
|
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();
|
ObjectNode point = objectMapper.createObjectNode();
|
||||||
point.put("id", qdrantPointId(knowledgeId, docId, i + 1));
|
point.put("id", pointId);
|
||||||
point.set("vector", vector);
|
point.set("vector", vector);
|
||||||
point.set("payload", payload);
|
point.set("payload", payload);
|
||||||
points.add(point);
|
points.add(point);
|
||||||
@@ -4325,10 +4428,14 @@ public class AihrSopSeedService {
|
|||||||
|
|
||||||
ObjectNode body = objectMapper.createObjectNode();
|
ObjectNode body = objectMapper.createObjectNode();
|
||||||
body.set("points", points);
|
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())) {
|
if (!ok(response.statusCode())) {
|
||||||
throw new IllegalStateException("qdrant upsert HTTP " + 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) {
|
private void deleteQdrantDoc(long knowledgeId, String docId) {
|
||||||
@@ -4339,7 +4446,7 @@ public class AihrSopSeedService {
|
|||||||
try {
|
try {
|
||||||
ObjectNode body = objectMapper.createObjectNode();
|
ObjectNode body = objectMapper.createObjectNode();
|
||||||
body.set("filter", qdrantFilter(knowledgeId, docId, null));
|
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) {
|
if (response.statusCode() == 404) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4370,14 +4477,46 @@ public class AihrSopSeedService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<VectorMatch> queryQdrant(String category, String embeddingJson, int limit, Set<Long> allowedKnowledgeIds) throws Exception {
|
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();
|
ObjectNode body = objectMapper.createObjectNode();
|
||||||
body.set("query", objectMapper.readTree(embeddingJson));
|
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("limit", limit);
|
||||||
body.put("with_payload", true);
|
body.put("with_payload", true);
|
||||||
body.put("with_vector", false);
|
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())) {
|
if (!ok(response.statusCode())) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
@@ -4388,15 +4527,78 @@ public class AihrSopSeedService {
|
|||||||
List<VectorMatch> matches = new ArrayList<>();
|
List<VectorMatch> matches = new ArrayList<>();
|
||||||
for (JsonNode point : points) {
|
for (JsonNode point : points) {
|
||||||
JsonNode payload = point.path("payload");
|
JsonNode payload = point.path("payload");
|
||||||
|
long knowledgeId = payload.path("knowledge_id").asLong(0L);
|
||||||
String docId = payload.path("doc_id").asText("");
|
String docId = payload.path("doc_id").asText("");
|
||||||
int idx = payload.path("idx").asInt(0);
|
int idx = payload.path("idx").asInt(0);
|
||||||
if (!docId.isBlank() && idx > 0) {
|
if (knowledgeId > 0 && !docId.isBlank() && idx > 0) {
|
||||||
matches.add(new VectorMatch(docId, idx, point.path("score").asDouble()));
|
matches.add(new VectorMatch(knowledgeId, docId, idx, point.path("score").asDouble()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return matches;
|
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() {
|
private VectorDbStats vectorDbStats() {
|
||||||
try {
|
try {
|
||||||
return jdbcTemplate.queryForObject("""
|
return jdbcTemplate.queryForObject("""
|
||||||
@@ -4438,7 +4640,7 @@ public class AihrSopSeedService {
|
|||||||
|
|
||||||
private QdrantStats qdrantStats() {
|
private QdrantStats qdrantStats() {
|
||||||
try {
|
try {
|
||||||
HttpResponse<String> response = qdrantRequest("GET", "/collections/" + qdrantCollection(), null);
|
HttpResponse<String> response = qdrantRequest("GET", "/collections/" + governedQdrantCollection(), null);
|
||||||
if (response.statusCode() == 404) {
|
if (response.statusCode() == 404) {
|
||||||
return new QdrantStats(null, 0L, "Qdrant collection 不存在");
|
return new QdrantStats(null, 0L, "Qdrant collection 不存在");
|
||||||
}
|
}
|
||||||
@@ -4456,8 +4658,8 @@ public class AihrSopSeedService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ensureQdrantCollection(int vectorSize) throws Exception {
|
private void ensureQdrantCollection(String collection, int vectorSize) throws Exception {
|
||||||
HttpResponse<String> current = qdrantRequest("GET", "/collections/" + qdrantCollection(), null);
|
HttpResponse<String> current = qdrantRequest("GET", "/collections/" + collection, null);
|
||||||
if (ok(current.statusCode())) {
|
if (ok(current.statusCode())) {
|
||||||
int currentSize = objectMapper.readTree(current.body())
|
int currentSize = objectMapper.readTree(current.body())
|
||||||
.path("result")
|
.path("result")
|
||||||
@@ -4480,7 +4682,7 @@ public class AihrSopSeedService {
|
|||||||
vectors.put("distance", "Cosine");
|
vectors.put("distance", "Cosine");
|
||||||
ObjectNode body = objectMapper.createObjectNode();
|
ObjectNode body = objectMapper.createObjectNode();
|
||||||
body.set("vectors", vectors);
|
body.set("vectors", vectors);
|
||||||
HttpResponse<String> created = qdrantRequest("PUT", "/collections/" + qdrantCollection(), body);
|
HttpResponse<String> created = qdrantRequest("PUT", "/collections/" + collection, body);
|
||||||
if (!ok(created.statusCode())) {
|
if (!ok(created.statusCode())) {
|
||||||
throw new IllegalStateException("qdrant create collection HTTP " + created.statusCode());
|
throw new IllegalStateException("qdrant create collection HTTP " + created.statusCode());
|
||||||
}
|
}
|
||||||
@@ -4490,7 +4692,7 @@ public class AihrSopSeedService {
|
|||||||
try {
|
try {
|
||||||
ObjectNode body = objectMapper.createObjectNode();
|
ObjectNode body = objectMapper.createObjectNode();
|
||||||
body.set("filter", qdrantProductionFilterForSpaces(null, null, null));
|
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) {
|
if (response.statusCode() == 404) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4507,7 +4709,7 @@ public class AihrSopSeedService {
|
|||||||
ObjectNode body = objectMapper.createObjectNode();
|
ObjectNode body = objectMapper.createObjectNode();
|
||||||
body.set("filter", qdrantProductionFilterForSpaces(null, null, null));
|
body.set("filter", qdrantProductionFilterForSpaces(null, null, null));
|
||||||
body.put("exact", true);
|
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())) {
|
if (!ok(response.statusCode())) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -4539,11 +4741,19 @@ public class AihrSopSeedService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ObjectNode qdrantProductionFilterForSpaces(Set<Long> knowledgeIds, String docId, String category) {
|
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);
|
ObjectNode filter = qdrantFilterForSpaces(knowledgeIds, docId, category);
|
||||||
ArrayNode must = (ArrayNode) filter.path("must");
|
ArrayNode must = (ArrayNode) filter.path("must");
|
||||||
must.add(qdrantMatch("dataset_code", "production"));
|
must.add(qdrantMatch("dataset_code", "production"));
|
||||||
must.add(qdrantMatch("lifecycle_status", "PUBLISHED"));
|
must.add(qdrantMatch("lifecycle_status", "PUBLISHED"));
|
||||||
must.add(qdrantMatch("trust_level", "HUMAN_VERIFIED"));
|
must.add(qdrantMatch("trust_level", "HUMAN_VERIFIED"));
|
||||||
|
if (generation != null) {
|
||||||
|
must.add(qdrantMatch("index_generation", generation));
|
||||||
|
}
|
||||||
return filter;
|
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"));
|
return normalizeBaseUrl(firstNonBlank(System.getProperty("aihr.qdrant.url"), System.getenv("AIHR_QDRANT_URL"), "http://127.0.0.1:6333"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String qdrantCollection() {
|
private static String legacyQdrantCollection() {
|
||||||
return firstNonBlank(System.getProperty("aihr.qdrant.collection"), System.getenv("AIHR_QDRANT_COLLECTION"), "aihr_knowledge");
|
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() {
|
private static String qdrantApiKey() {
|
||||||
@@ -4980,6 +5197,11 @@ public class AihrSopSeedService {
|
|||||||
return UUID.nameUUIDFromBytes((tenantId() + ":" + knowledgeId + ":" + docId + ":" + idx).getBytes(StandardCharsets.UTF_8)).toString();
|
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) {
|
private static boolean ok(int statusCode) {
|
||||||
return statusCode >= 200 && statusCode < 300;
|
return statusCode >= 200 && statusCode < 300;
|
||||||
}
|
}
|
||||||
@@ -5185,6 +5407,10 @@ public class AihrSopSeedService {
|
|||||||
private record DocumentVectorRow(String category, String content) {
|
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) {
|
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 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) {
|
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")
|
@Tag("dev")
|
||||||
class AihrKnowledgeCitationDetailServiceTest {
|
class AihrKnowledgeCitationDetailServiceTest {
|
||||||
@Test
|
@Test
|
||||||
void detailAndAdjacentSegmentsRequireCurrentPublishedLifecycle() throws Exception {
|
void detailAndAdjacentSegmentsUseTheSharedRolloutLifecyclePredicate() throws Exception {
|
||||||
Path source = Path.of(
|
Path source = Path.of(
|
||||||
"src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeCitationDetailService.java");
|
"src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeCitationDetailService.java");
|
||||||
if (!Files.exists(source)) {
|
if (!Files.exists(source)) {
|
||||||
@@ -33,10 +33,7 @@ class AihrKnowledgeCitationDetailServiceTest {
|
|||||||
}
|
}
|
||||||
String code = Files.readString(source);
|
String code = Files.readString(source);
|
||||||
|
|
||||||
assertTrue(code.split("governed_chunk.published_fragment_id = f.id", -1).length - 1 >= 2);
|
assertTrue(code.split("servingFragmentExistsSql", -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'"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+2
-7
@@ -55,7 +55,7 @@ import static org.mockito.ArgumentMatchers.eq;
|
|||||||
class AihrKnowledgeQueryServiceTest {
|
class AihrKnowledgeQueryServiceTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void citationHydrationAndResourceDownloadsRequirePublishedLifecycle() throws Exception {
|
void resourceDownloadsUseTheSharedRolloutLifecyclePredicate() throws Exception {
|
||||||
Path source = Path.of(
|
Path source = Path.of(
|
||||||
"src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java");
|
"src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java");
|
||||||
if (!Files.exists(source)) {
|
if (!Files.exists(source)) {
|
||||||
@@ -64,12 +64,7 @@ class AihrKnowledgeQueryServiceTest {
|
|||||||
}
|
}
|
||||||
String code = Files.readString(source);
|
String code = Files.readString(source);
|
||||||
|
|
||||||
assertTrue(code.contains("governed_chunk.published_fragment_id = f.id"));
|
assertTrue(code.contains("AihrKnowledgeRolloutService.servingAttachmentExistsSql(\"a\")"));
|
||||||
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'"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+3
-2
@@ -19,6 +19,7 @@ import static org.mockito.ArgumentMatchers.startsWith;
|
|||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@Tag("dev")
|
@Tag("dev")
|
||||||
@@ -59,7 +60,7 @@ class AihrKnowledgeResourceDownloadServiceTest {
|
|||||||
verify(redisson).getBucket(GlobalConstants.GLOBAL_REDIS_KEY + "aihr:knowledge:resource-download:321:" + ticket);
|
verify(redisson).getBucket(GlobalConstants.GLOBAL_REDIS_KEY + "aihr:knowledge:resource-download:321:" + ticket);
|
||||||
verify(response).setHeader("Cache-Control", "no-store");
|
verify(response).setHeader("Cache-Control", "no-store");
|
||||||
verify(response).setHeader("Referrer-Policy", "no-referrer");
|
verify(response).setHeader("Referrer-Policy", "no-referrer");
|
||||||
verify(ossService).download(777L, response);
|
verify(ossService).downloadRange(777L, null, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -74,6 +75,6 @@ class AihrKnowledgeResourceDownloadServiceTest {
|
|||||||
|
|
||||||
verify(response).sendError(HttpServletResponse.SC_NOT_FOUND, "资料不存在或无权访问");
|
verify(response).sendError(HttpServletResponse.SC_NOT_FOUND, "资料不存在或无权访问");
|
||||||
verify(redisson, never()).getBucket(anyString());
|
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");
|
"aihr_20260812_pipeline_run_sampling_mysql8.sql");
|
||||||
private static final Path GOLDEN_CALIBRATION_MIGRATION = Path.of("..", "..", "script", "sql", "update",
|
private static final Path GOLDEN_CALIBRATION_MIGRATION = Path.of("..", "..", "script", "sql", "update",
|
||||||
"aihr_20260813_golden_calibration_mysql8.sql");
|
"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",
|
private static final Path LIFECYCLE_SERVICE = Path.of("src", "main", "java", "org", "dromara", "aihr",
|
||||||
"knowledge", "quality", "AihrKnowledgeLifecycleService.java");
|
"knowledge", "quality", "AihrKnowledgeLifecycleService.java");
|
||||||
|
|
||||||
@@ -196,4 +210,78 @@ class AihrDataQualityLifecycleSchemaTest {
|
|||||||
assertThat(sql.toLowerCase()).doesNotContain("update aihr_processing_rule");
|
assertThat(sql.toLowerCase()).doesNotContain("update aihr_processing_rule");
|
||||||
assertThat(sql.toLowerCase()).doesNotContain("lifecycle_status = 'published'");
|
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 org.springframework.jdbc.core.RowMapper;
|
||||||
|
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
@@ -24,6 +26,18 @@ import static org.mockito.Mockito.when;
|
|||||||
@Tag("dev")
|
@Tag("dev")
|
||||||
class AihrKnowledgeLegacyMigrationServiceTest {
|
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
|
@Test
|
||||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||||
void previewReportsRawAvailabilityWithoutStagingAnything() throws Exception {
|
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(\"dataset_code\", \"production\")"));
|
||||||
assertTrue(code.contains("payload.put(\"lifecycle_status\", \"PUBLISHED\")"));
|
assertTrue(code.contains("payload.put(\"lifecycle_status\", \"PUBLISHED\")"));
|
||||||
assertTrue(code.contains("payload.put(\"trust_level\", \"HUMAN_VERIFIED\")"));
|
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
|
@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;
|
||||||
@@ -299,8 +299,43 @@ POST /api/knowledge/quality/assets/{assetId}/publish
|
|||||||
POST /api/knowledge/quality/assets/{assetId}/withdraw
|
POST /api/knowledge/quality/assets/{assetId}/withdraw
|
||||||
GET /api/knowledge/quality/legacy/preview?afterAttachmentId=0&limit=50
|
GET /api/knowledge/quality/legacy/preview?afterAttachmentId=0&limit=50
|
||||||
POST /api/knowledge/quality/legacy/stage?afterAttachmentId=0&limit=50
|
POST /api/knowledge/quality/legacy/stage?afterAttachmentId=0&limit=50
|
||||||
|
GET /api/knowledge/quality/assets/{assetId}/versions
|
||||||
|
GET /api/knowledge/quality/assets/{assetId}/versions/{versionId}
|
||||||
|
GET /api/knowledge/quality/assets/{assetId}/versions/diff?fromVersionId=1&toVersionId=2
|
||||||
|
GET /api/knowledge/quality/assets/{assetId}/review-package
|
||||||
|
POST /api/knowledge/quality/assets/review-batches
|
||||||
|
POST /api/knowledge/quality/assets/{assetId}/rollback
|
||||||
|
GET /api/knowledge/quality/assets/{assetId}/impact
|
||||||
|
POST /api/knowledge/quality/assets/{assetId}/purge-requests
|
||||||
|
GET /api/knowledge/quality/purge-requests
|
||||||
|
POST /api/knowledge/quality/purge-requests/{requestId}/decision
|
||||||
|
POST /api/knowledge/quality/purge-requests/{requestId}/execute
|
||||||
|
POST /api/knowledge/quality/reconciliation-runs
|
||||||
|
POST /api/knowledge/quality/migration-runs
|
||||||
|
GET /api/knowledge/quality/migration-runs
|
||||||
|
POST /api/knowledge/quality/migration-runs/{runId}/next
|
||||||
|
GET /api/knowledge/quality/migration-runs/{runId}/dead-letters
|
||||||
|
POST /api/knowledge/quality/migration-dead-letters/{deadLetterId}/retry
|
||||||
|
GET /api/knowledge/quality/rollout/scopes
|
||||||
|
POST /api/knowledge/quality/rollout/spaces/{knowledgeId}/transition
|
||||||
|
POST /api/knowledge/quality/rollout/spaces/{knowledgeId}/generation-builds
|
||||||
|
POST /api/knowledge/quality/rollout/spaces/{knowledgeId}/generation-builds/refresh
|
||||||
|
POST /api/knowledge/quality/rollout/spaces/{knowledgeId}/shadow-comparisons
|
||||||
|
POST /api/knowledge/quality/rollout/spaces/{knowledgeId}/activation-gates
|
||||||
|
POST /api/knowledge/quality/rollout/spaces/{knowledgeId}/activate
|
||||||
|
POST /api/knowledge/quality/rollout/spaces/{knowledgeId}/generation-rollback
|
||||||
```
|
```
|
||||||
|
|
||||||
|
资料资产使用独立的 `published_version_id` 与 `candidate_version_id`。上传和重处理只推进候选指针,不能覆盖正在服务的生效版;发布在同一事务内切换生效指针、生产数据集成员和索引 outbox,旧版本转为 `SUPERSEDED` 并保留不可变片段。版本差异按正文 hash 与 chunk 序号生成可复算证据,回滚必须由登录审核人填写原因,且目标必须是曾经发布、仍有生产成员证据和不可变片段的历史版本。
|
||||||
|
|
||||||
|
审核包只提供风险、重复、冲突、质量问题和建议动作,不代替人工决定。低风险批量发布必须由人工显式发起、携带幂等 `batchKey`,逐项重新校验候选版本;`MEDIUM/HIGH/CRITICAL` 不进入批量发布。并发审核通过 `expectedVersionId` 拒绝已经换过候选版的陈旧操作。
|
||||||
|
|
||||||
|
撤回是可恢复的在线下线:立即停用生产成员并排队删除当前 generation 的向量,但保留版本和片段供审计/回滚。物理清理只能针对已撤回资产,先生成影响快照,再进入 `PENDING -> APPROVED -> EXECUTING -> EXECUTED/FAILED`;申请人与批准/执行人必须不同,`legal_hold`、`PERMANENT` 或未到 `execute_after` 一律拒绝。保留期为 `TEMPORARY=7`、`STANDARD=90`、`REGULATED=365` 天。独占 OSS、MySQL 版本/片段/证据和对应向量按审计请求清理,`aihr_deletion_request` 永久保留为墓碑;`reconciliation-runs` 用于发现 MySQL、OSS 元数据、向量计数和 chunk 向量血缘漂移。
|
||||||
|
|
||||||
|
历史补链使用迁移清单而不是无状态循环调用。每个 `migration-run` 冻结租户、可选知识空间、风险范围、dry-run、批大小和 manifest hash;`next` 只推进已持久化游标并记录批次证据。`LOW_RISK` 只扫描租户可验证 OSS 原件,`QUARANTINE_FIRST` 只扫描缺失原件且需要隔离的资料,`ALL` 扫描全部候选。失败附件进入显式 dead letter,最多按退避策略重试,不能因为单项失败回滚或隐藏仍由 LEGACY 服务的线上资料。正式任务必须提交 `verifiedDryRunId`,服务端只接受已完成且知识空间、风险范围、批大小完全一致的演练;管理端创建正式任务后仍需人工逐批推进,不会自动修改资料。
|
||||||
|
|
||||||
|
空间发布固定为 `LEGACY -> SHADOW -> ENFORCED`。SHADOW 仍服务旧链路,候选 generation 在独立 collection/generation 中全量构建;管理端可记录真实问题的双链路对照。启用门禁要求候选 generation 为 `READY`、至少 20 个样本、Top1 一致率不低于 90%、Top5 平均重合率不低于 80%、治理结果空集率不高于 1%,且无覆盖缺口、开放关键问题或索引死信。旧 transition 接口不能直接进入 ENFORCED;只有人工使用最新 `PASSED` gate 调用 `activate` 才能切换。原 generation 保留 7 天回退窗口,回退后恢复 SHADOW。
|
||||||
|
|
||||||
`publish` 必须带人工审核意见、用途、来源权威级别和来源版本。请求中的 `acceptedReasonCodes` 只能确认当前版本仍开放的软提示,服务端会再次统计开放问题,遗漏任何一项都拒绝发布;`HARD` 问题不能在发布动作中接受,必须修订原资料并生成新版本,或通过后续独立的显式纠错流程解决。规范性知识仅接受 `FORMAL_POLICY / COMPANY_POLICY / REGULATION / APPROVED_SOP` 且版本非空;同名已发布资料内容变化会产生 `VERSION_CONFLICT`,规范性知识必须再传 `supersedesAssetId`,服务端验证同租户、同来源名和旧资产仍已发布后,在同一事务内撤回旧版本并发布新版本。
|
`publish` 必须带人工审核意见、用途、来源权威级别和来源版本。请求中的 `acceptedReasonCodes` 只能确认当前版本仍开放的软提示,服务端会再次统计开放问题,遗漏任何一项都拒绝发布;`HARD` 问题不能在发布动作中接受,必须修订原资料并生成新版本,或通过后续独立的显式纠错流程解决。规范性知识仅接受 `FORMAL_POLICY / COMPANY_POLICY / REGULATION / APPROVED_SOP` 且版本非空;同名已发布资料内容变化会产生 `VERSION_CONFLICT`,规范性知识必须再传 `supersedesAssetId`,服务端验证同租户、同来源名和旧资产仍已发布后,在同一事务内撤回旧版本并发布新版本。
|
||||||
|
|
||||||
规范化采用版本化的 NFKC、换行、控制字符和空白处理;解析正文和规范化正文作为不可变审计证据保留,不作为下游生产内容。`privacy-redaction-v1` 从规范化正文生成独立的 `redacted_content/redacted_source_name`,记录派生 hash、分类计数、隐私状态和规则版本,并对派生正文及全部派生 chunk 复扫。只有 `CLEAN/REDACTED` 派生版本参与结构化切片、异步语义分析、模型调用、发布和检索;重复文件复用的历史摘要、模型新生成的摘要/标签/归类理由以及上传 snippet 也必须在返回或保存前走同一脱敏规则,不能因 OSS 对象复用而绕过。残留敏感模式写入 `BLOCKED / PII_DETECTED` 并拒绝发布,已完成替换写入 `PII_REDACTED` 证据。`GET /assets/{assetId}/privacy-preview` 只返回脱敏文件名、分类计数和截断后的脱敏正文,不返回原始正文。
|
规范化采用版本化的 NFKC、换行、控制字符和空白处理;解析正文和规范化正文作为不可变审计证据保留,不作为下游生产内容。`privacy-redaction-v1` 从规范化正文生成独立的 `redacted_content/redacted_source_name`,记录派生 hash、分类计数、隐私状态和规则版本,并对派生正文及全部派生 chunk 复扫。只有 `CLEAN/REDACTED` 派生版本参与结构化切片、异步语义分析、模型调用、发布和检索;重复文件复用的历史摘要、模型新生成的摘要/标签/归类理由以及上传 snippet 也必须在返回或保存前走同一脱敏规则,不能因 OSS 对象复用而绕过。残留敏感模式写入 `BLOCKED / PII_DETECTED` 并拒绝发布,已完成替换写入 `PII_REDACTED` 证据。`GET /assets/{assetId}/privacy-preview` 只返回脱敏文件名、分类计数和截断后的脱敏正文,不返回原始正文。
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
>
|
>
|
||||||
> 2026-07-30 增量新增并发布五通道处理人配置:只有 `superadmin` 可查看当前租户处理人、检索合格候选并增删角色绑定;服务端排除 APP 用户、超级管理员、停用/删除和跨租户账号,写请求复核 `expectedTenantId`。生产五个角色均为 `2/2`,本通道正例、跨通道拒绝和一次正式回复已完成 `5/5`,2026-07-31 纳入受控内部公测签认。
|
> 2026-07-30 增量新增并发布五通道处理人配置:只有 `superadmin` 可查看当前租户处理人、检索合格候选并增删角色绑定;服务端排除 APP 用户、超级管理员、停用/删除和跨租户账号,写请求复核 `expectedTenantId`。生产五个角色均为 `2/2`,本通道正例、跨通道拒绝和一次正式回复已完成 `5/5`,2026-07-31 纳入受控内部公测签认。
|
||||||
>
|
>
|
||||||
|
> 2026-08-03 当前工作树完成数字资产全生命周期治理的本地实现与验证,尚未部署:先以按空间 `LEGACY/SHADOW/ENFORCED` 兼容层保护线上旧资料,再依次提供候选/生效双指针、不可变版本与审核辅助、generation 向量血缘、撤回与回滚、保留/法务冻结/异人清理、可续跑历史补链、影子对照和人工启用门禁。生产不得跳过兼容层直接执行七阶段,也不得在发布窗口执行物理清理或自动切换现有空间。
|
||||||
|
>
|
||||||
> 本文只维护当前结论、证据层级和剩余门槛。逐提交发布流水已从当前真相文档移除,需要追溯时使用 Git 历史;不得再用 2026-07-14/15 的“尚未发布”记录判断现网。
|
> 本文只维护当前结论、证据层级和剩余门槛。逐提交发布流水已从当前真相文档移除,需要追溯时使用 Git 历史;不得再用 2026-07-14/15 的“尚未发布”记录判断现网。
|
||||||
|
|
||||||
## 1. 当前结论
|
## 1. 当前结论
|
||||||
@@ -26,10 +28,10 @@
|
|||||||
|
|
||||||
| 层级 | 当前状态 | 证据与边界 |
|
| 层级 | 当前状态 | 证据与边界 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 已实现 | 是 | 已有阶段一主功能;当前工作树另实现大喇叭投放控制、反馈转人工、正式学习任务/随机组卷、案例经验治理、原生异常恢复和上传容量门禁;接口事实见 [API_INTEGRATION.md](API_INTEGRATION.md) |
|
| 已实现 | 是 | 已有阶段一主功能;当前工作树另实现大喇叭投放控制、反馈转人工、正式学习任务/随机组卷、案例经验治理、原生异常恢复、上传容量门禁,以及数字资产兼容放量、版本、审核、向量代次、撤回/回滚、保留清理、历史迁移和影子启用全链路;接口事实见 [API_INTEGRATION.md](API_INTEGRATION.md) |
|
||||||
| 本地自动化/API 验证 | 是 | 当前工作树管理端生产构建、移动端类型检查和 `216/216` 单测通过;后端 Surefire 共 `107` 份报告、`838` 项测试,失败/错误/跳过均为 `0`;迁移静态清单通过,Docker/MySQL 幂等执行未完成 |
|
| 本地自动化/API 验证 | 是 | 当前工作树管理端生产构建通过;后端完整测试 `851/851`,失败/错误/跳过均为 `0`;发布预检范围契约通过,生命周期迁移已在现有 MySQL 连续执行两次且资产数保持 `104`,未创建清理或迁移任务 |
|
||||||
| 本地浏览器/视觉验证 | 部分 | `390×844` 已实际点击员工“今日/练/问”、大喇叭登录门禁和直通车登录门禁,无横向溢出或重叠;管理端认证后的案例发布弹窗因后端/MySQL 未运行而未验证。既有生产公司消息回归和 Android ASR 证据不外推到本批增量 |
|
| 本地浏览器/视觉验证 | 部分 | `390×844` 已实际点击员工“今日/练/问”、大喇叭登录门禁和直通车登录门禁,无横向溢出或重叠;2026-08-03 已在当前工作树管理端登录并实际打开空间放量、迁移/清理、版本审核辅助和影子启用对话框,桌面布局无明显重叠或截断,未提交任何状态变更。窄屏管理端本轮未复核 |
|
||||||
| 已部署 | 部分 | 2026-07-25 至 2026-07-31 的既有生产基线已部署;当前工作树的四个 `aihr_20260802_*` 迁移及对应后端、管理端和移动端增量均未部署 |
|
| 已部署 | 部分 | 2026-07-25 至 2026-07-31 的既有生产基线已部署;当前工作树的 `aihr_20260815_*` 至 `aihr_20260821_*` 生命周期迁移及对应后端、管理端均未部署,线上资料未发生本批变更 |
|
||||||
| 生产基础验证 | 是 | 服务 `active`;根站、租户接口和移动首页 API 正常;媒体重试列/索引契约通过,4 个历史零片段任务均恢复为已完成且有片段/向量。当前未执行包含移动 H5 的完整包匹配 |
|
| 生产基础验证 | 是 | 服务 `active`;根站、租户接口和移动首页 API 正常;媒体重试列/索引契约通过,4 个历史零片段任务均恢复为已完成且有片段/向量。当前未执行包含移动 H5 的完整包匹配 |
|
||||||
| 生产完整业务验收 | 部分 | 公司消息已完成认证态全员文件消息和直通车闭环,单台 Android 标准基座已完成生产 ASR;训练写入、主管复盘、定向人群正反例、正式处理人绑定、其他真机媒体与严格试点窗口仍未完成 |
|
| 生产完整业务验收 | 部分 | 公司消息已完成认证态全员文件消息和直通车闭环,单台 Android 标准基座已完成生产 ASR;训练写入、主管复盘、定向人群正反例、正式处理人绑定、其他真机媒体与严格试点窗口仍未完成 |
|
||||||
|
|
||||||
@@ -43,6 +45,14 @@
|
|||||||
- 运行门禁:资料暂存盘默认要求至少 10GiB 可用空间,生产 HTTP 请求启用 P50/P95/P99 指标;约 600 名用户不是 600 并发,压测模型和通过阈值仍待业务、实施和运维签认。
|
- 运行门禁:资料暂存盘默认要求至少 10GiB 可用空间,生产 HTTP 请求启用 P50/P95/P99 指标;约 600 名用户不是 600 并发,压测模型和通过阈值仍待业务、实施和运维签认。
|
||||||
- 发布边界:四个正式迁移已纳入 Runbook;本地 Docker 未运行,未执行 MySQL 首次/重复迁移、完整后端认证 API、签名 APK、真机或生产验证。
|
- 发布边界:四个正式迁移已纳入 Runbook;本地 Docker 未运行,未执行 MySQL 首次/重复迁移、完整后端认证 API、签名 APK、真机或生产验证。
|
||||||
|
|
||||||
|
### 2026-08-03 数字资产生命周期本地实现边界
|
||||||
|
|
||||||
|
- 执行顺序已经重新编排,不是把自动化辅助作为七阶段之外的平行项目:阶段 0 兼容层必须先落地,随后七阶段能力按依赖进入同一发布计划;系统先生成清单、风险、差异、影响和建议,人工只负责业务判断、异人批准和最终启用。
|
||||||
|
- 既有资料默认继续由 `LEGACY` 服务。历史补链必须先创建固定范围的 dry-run,只有无失败完成且知识空间、风险范围、批大小完全匹配的演练,才能生成正式任务;正式任务仍需人工逐批推进,单项失败进入 dead letter,不中断旧读链路。
|
||||||
|
- 新上传或修订只推进候选版本,生效版本继续提供检索;发布、撤回、回滚和 generation 切换均保留审计证据。日常删除语义为可恢复撤回,物理清理另受保留期、法务冻结、引用影响和异人控制约束。
|
||||||
|
- 生产启用固定为单空间 `LEGACY -> SHADOW -> ENFORCED`。SHADOW 期间构建隔离候选 generation 并记录真实问题对照;覆盖、索引、质量和样本门禁通过后,才允许人工确认启用,并保留七天 generation 回退窗口。
|
||||||
|
- 本地浏览器仅执行只读查看和打开对话框。验收前后数据库均为资产 `104`、非活动删除状态 `0`、清理申请 `0`、迁移任务 `0`、影子样本 `0`、generation 启用 `0`;已有一个本地 SHADOW 空间保持不变。以上不能外推为生产部署或线上迁移完成。
|
||||||
|
|
||||||
### 2026-07-29 资料处理媒体恢复发布边界
|
### 2026-07-29 资料处理媒体恢复发布边界
|
||||||
|
|
||||||
历史图片/视频在视觉模型不接受原始分辨率/编码,或模型、ASR、关键帧链路没有返回可检索文字时,旧逻辑会留下状态 0、片段 0 的附件,页面长期显示“等待解析”,却没有从原件重新入队的入口。当前实现先对图片纠正方向、缩放和重编码;异步队列只有生成至少一个片段才进入完成态,零片段明确保持可重试失败。管理端可调用 `POST /api/knowledge/doc/processing-tasks/{attachmentId}/retry`,服务端按当前租户从原 OSS 对象重新暂存,通过附件状态 CAS 防止重复入队,并用 `source_attach_id` 关联新队列条目。
|
历史图片/视频在视觉模型不接受原始分辨率/编码,或模型、ASR、关键帧链路没有返回可检索文字时,旧逻辑会留下状态 0、片段 0 的附件,页面长期显示“等待解析”,却没有从原件重新入队的入口。当前实现先对图片纠正方向、缩放和重编码;异步队列只有生成至少一个片段才进入完成态,零片段明确保持可重试失败。管理端可调用 `POST /api/knowledge/doc/processing-tasks/{attachmentId}/retry`,服务端按当前租户从原 OSS 对象重新暂存,通过附件状态 CAS 防止重复入队,并用 `source_attach_id` 关联新队列条目。
|
||||||
|
|||||||
@@ -166,6 +166,13 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih
|
|||||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260812_pipeline_run_sampling_mysql8.sql
|
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260812_pipeline_run_sampling_mysql8.sql
|
||||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260813_golden_calibration_mysql8.sql
|
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260813_golden_calibration_mysql8.sql
|
||||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260814_knowledge_privacy_derivative_mysql8.sql
|
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260814_knowledge_privacy_derivative_mysql8.sql
|
||||||
|
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260815_knowledge_rollout_compat_mysql8.sql
|
||||||
|
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260816_knowledge_version_pointers_mysql8.sql
|
||||||
|
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260817_knowledge_review_assistance_mysql8.sql
|
||||||
|
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260818_knowledge_generation_revision_mysql8.sql
|
||||||
|
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260819_knowledge_retention_deletion_mysql8.sql
|
||||||
|
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260820_knowledge_migration_orchestration_mysql8.sql
|
||||||
|
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260821_knowledge_shadow_gate_mysql8.sql
|
||||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_personal_knowledge_mysql8.sql
|
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_personal_knowledge_mysql8.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -173,6 +180,10 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_perso
|
|||||||
|
|
||||||
随后按 `20260808 -> 20260809 -> 20260810 -> 20260811 -> 20260812 -> 20260813 -> 20260814` 执行质量监控、人工术语修订、AI 案例来源、规则演进、处理批次、黄金集校准和隐私派生迁移。`20260811` 只增加规则草稿、状态审计、影子对照和抽样复核;`20260812` 只增加 `pipeline_run` 与自动生成待人工复核样本的调度,不启用规则执法,不改变任何资产生命周期。`20260814` 只增加独立脱敏正文、脱敏文件名、派生 hash、隐私状态、规则版本和复扫摘要字段,历史版本默认保持 `NOT_PROCESSED`,不得通过 SQL 伪造脱敏完成或自动获批。完整迁移后仍须最后执行排序规则兼容迁移。
|
随后按 `20260808 -> 20260809 -> 20260810 -> 20260811 -> 20260812 -> 20260813 -> 20260814` 执行质量监控、人工术语修订、AI 案例来源、规则演进、处理批次、黄金集校准和隐私派生迁移。`20260811` 只增加规则草稿、状态审计、影子对照和抽样复核;`20260812` 只增加 `pipeline_run` 与自动生成待人工复核样本的调度,不启用规则执法,不改变任何资产生命周期。`20260814` 只增加独立脱敏正文、脱敏文件名、派生 hash、隐私状态、规则版本和复扫摘要字段,历史版本默认保持 `NOT_PROCESSED`,不得通过 SQL 伪造脱敏完成或自动获批。完整迁移后仍须最后执行排序规则兼容迁移。
|
||||||
|
|
||||||
|
生命周期兼容发布继续按 `20260815 -> 20260816 -> 20260817 -> 20260818 -> 20260819 -> 20260820 -> 20260821` 执行:先建立按空间的 `LEGACY/SHADOW/ENFORCED` 兼容范围,再增加候选/生效双指针、审核辅助、不可变 generation 与回滚证据、保留和异人删除、历史迁移清单、影子指标与启用门禁。七个脚本均为增量结构迁移;不得在 SQL 阶段自动发布、撤回、删除或切换现有空间。迁移完成后所有空间仍应为 LEGACY 或保持迁移前显式模式,历史资料继续可见。生产发布必须先只读统计资产、版本、生产成员、fragment、OSS 和 Qdrant 数量并备份;先以单空间 dry-run 和 SHADOW 验证,达到门禁后由人工启用。正式迁移任务必须通过 `verified_dry_run_id` 绑定同知识空间、同风险范围、同批大小且无失败的已完成演练;创建正式任务不自动推进批次。任何补链失败都停留在迁移 dead letter,不得切断旧读路径。
|
||||||
|
|
||||||
|
生产物理删除不是部署步骤,也不得与 schema/JAR 发布同窗口批量执行。日常纠错只做 `withdraw`;`purge` 必须有业务/法务确认的保留分类、影响快照、异人批准和已到期 `execute_after`。发布后先运行 reconciliation 并处理不一致,再考虑清理。ENFORCED 启用后旧 generation 保留七天;窗口内指标恶化可从管理端回退上一代,超过窗口必须重新构建候选 generation 和重新通过门禁。
|
||||||
|
|
||||||
8 月 1 日受控内部试点的正式来源注册是独立的业务数据变更,不随通用 schema 自动执行。先逐一核对附件 `id + doc_id`、原文件 SHA-256、发文号/版本、生效日期和适用范围,再由已授权负责人执行 `backend/script/sql/ops/aihr_20260730_aug1_formal_source_registry_mysql8.sql`。执行后必须只读确认恰好 10 条 `FORMAL_POLICY + APPROVED` 记录;任何缺失都保持无正式依据,不得把访谈、经验萃取、AI 生成内容或草稿补进白名单。
|
8 月 1 日受控内部试点的正式来源注册是独立的业务数据变更,不随通用 schema 自动执行。先逐一核对附件 `id + doc_id`、原文件 SHA-256、发文号/版本、生效日期和适用范围,再由已授权负责人执行 `backend/script/sql/ops/aihr_20260730_aug1_formal_source_registry_mysql8.sql`。执行后必须只读确认恰好 10 条 `FORMAL_POLICY + APPROVED` 记录;任何缺失都保持无正式依据,不得把访谈、经验萃取、AI 生成内容或草稿补进白名单。
|
||||||
|
|
||||||
顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;五岗位题库之后的内容运营迁移为每个场景补齐唯一的初始 Rubric、五个稳定评分维度与训练时评分规则快照列,并只修正仍处于待训练状态的旧每日题场景编号,不重写已完成记录。紧随其后的成长目录迁移只在既有场景和会话表上增加岗位/层级/能力项/审核状态与会话快照,以及复盘会话专用的 `growth_confirmation_level/growth_confirmed_by/growth_confirmed_time`;它只允许记录主管对当前会话下一训练层级的确认,不改写原训练快照或人事结论,并补齐/修复派发请求的 `(tenant_id,request_key,ext_party_id)` 唯一键与最近内容过滤索引。它保留既有启停可用性和历史会话,不把迁移结果写成业务内容签字。生产环境不得设置 `AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP=true`:应用请求只读校验表、列和关键索引,任何缺失都必须先执行本节正式 SQL,不能由任一用户请求触发补列、补索引或状态回填。岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;正式知识来源治理迁移只创建附件级权威类型、生命周期、版本、生效期、适用范围和原文件哈希契约,不自动批准任何历史附件。移动端政策问答只使用有效的 `FORMAL_POLICY + APPROVED` 注册项,治理表缺失、来源未批准、已失效、版本或哈希为空时均按无正式依据关闭。银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。直通车迁移创建点对点反馈表,并给正式租户 `000000` 幂等预置总裁、财务、人力、审计、运营五个处理角色;人员仍须由管理员按职责分配,业务匿名不抹除内部审计身份。公司文件迁移创建异步提炼表并给消息增加可空 `attachment_id`,不迁移或公开已有文件;紧随其后的多岗位解读迁移只在附件表增加生成状态、经引用校验的结构化结果和规则版本,并建立异步队列索引,不回填旧文件、不改变文件 READY 或消息发布状态。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。Agent 迁移只新增最小运行审计表,不保存问题、答案、密钥或模型推理。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。
|
顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;五岗位题库之后的内容运营迁移为每个场景补齐唯一的初始 Rubric、五个稳定评分维度与训练时评分规则快照列,并只修正仍处于待训练状态的旧每日题场景编号,不重写已完成记录。紧随其后的成长目录迁移只在既有场景和会话表上增加岗位/层级/能力项/审核状态与会话快照,以及复盘会话专用的 `growth_confirmation_level/growth_confirmed_by/growth_confirmed_time`;它只允许记录主管对当前会话下一训练层级的确认,不改写原训练快照或人事结论,并补齐/修复派发请求的 `(tenant_id,request_key,ext_party_id)` 唯一键与最近内容过滤索引。它保留既有启停可用性和历史会话,不把迁移结果写成业务内容签字。生产环境不得设置 `AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP=true`:应用请求只读校验表、列和关键索引,任何缺失都必须先执行本节正式 SQL,不能由任一用户请求触发补列、补索引或状态回填。岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;正式知识来源治理迁移只创建附件级权威类型、生命周期、版本、生效期、适用范围和原文件哈希契约,不自动批准任何历史附件。移动端政策问答只使用有效的 `FORMAL_POLICY + APPROVED` 注册项,治理表缺失、来源未批准、已失效、版本或哈希为空时均按无正式依据关闭。银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。直通车迁移创建点对点反馈表,并给正式租户 `000000` 幂等预置总裁、财务、人力、审计、运营五个处理角色;人员仍须由管理员按职责分配,业务匿名不抹除内部审计身份。公司文件迁移创建异步提炼表并给消息增加可空 `attachment_id`,不迁移或公开已有文件;紧随其后的多岗位解读迁移只在附件表增加生成状态、经引用校验的结构化结果和规则版本,并建立异步队列索引,不回填旧文件、不改变文件 READY 或消息发布状态。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。Agent 迁移只新增最小运行审计表,不保存问题、答案、密钥或模型推理。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。
|
||||||
|
|||||||
@@ -187,6 +187,129 @@ export type LegacyStagePreview = {
|
|||||||
moreAvailable: boolean;
|
moreAvailable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type KnowledgeRolloutMode = 'LEGACY' | 'SHADOW' | 'ENFORCED';
|
||||||
|
|
||||||
|
export type KnowledgeRolloutScope = {
|
||||||
|
knowledgeId: number;
|
||||||
|
spaceCode: string;
|
||||||
|
spaceName: string;
|
||||||
|
mode: KnowledgeRolloutMode;
|
||||||
|
governedCollection: string;
|
||||||
|
activeGeneration: number;
|
||||||
|
candidateGeneration?: number;
|
||||||
|
updatedBy?: number;
|
||||||
|
reason?: string;
|
||||||
|
updatedTime?: string;
|
||||||
|
readyAttachments: number;
|
||||||
|
enforcementBlockers: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type KnowledgeVersion = {
|
||||||
|
id: number;
|
||||||
|
versionNo: number;
|
||||||
|
versionStatus: string;
|
||||||
|
revisionOfVersionId?: number;
|
||||||
|
revisionReason?: string;
|
||||||
|
contentSha256: string;
|
||||||
|
parserName?: string;
|
||||||
|
parserVersion?: string;
|
||||||
|
semanticAnalysisStatus: string;
|
||||||
|
privacyStatus: string;
|
||||||
|
published: boolean;
|
||||||
|
candidate: boolean;
|
||||||
|
chunkCount: number;
|
||||||
|
openIssueCount: number;
|
||||||
|
createTime: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReviewPackage = {
|
||||||
|
assetId: number;
|
||||||
|
versionId: number;
|
||||||
|
versionNo: number;
|
||||||
|
riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||||
|
batchEligible: boolean;
|
||||||
|
summary: {
|
||||||
|
sourceName: string;
|
||||||
|
contentPreview: string;
|
||||||
|
headings: string[];
|
||||||
|
chunkCount: number;
|
||||||
|
issueCount: number;
|
||||||
|
duplicateCount: number;
|
||||||
|
conflictCount: number;
|
||||||
|
};
|
||||||
|
suggestion: { decision: string; reasonCodes: string[]; recommendedActions: string[]; disclaimer: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeletionImpact = {
|
||||||
|
assetId: number;
|
||||||
|
lifecycleStatus: string;
|
||||||
|
retentionClass: string;
|
||||||
|
legalHold: boolean;
|
||||||
|
chunks: number;
|
||||||
|
publishedFragments: number;
|
||||||
|
queryEvidence: number;
|
||||||
|
learningTasks: number;
|
||||||
|
broadcasts: number;
|
||||||
|
generations: number;
|
||||||
|
canRequestPurge: boolean;
|
||||||
|
blockers: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PurgeRequest = {
|
||||||
|
id: number;
|
||||||
|
requestKey: string;
|
||||||
|
assetId: number;
|
||||||
|
retentionClass: string;
|
||||||
|
reason: string;
|
||||||
|
status: string;
|
||||||
|
requesterId: number;
|
||||||
|
requestedTime: string;
|
||||||
|
approverId?: number;
|
||||||
|
approvalReason?: string;
|
||||||
|
executeAfter: string;
|
||||||
|
executedTime?: string;
|
||||||
|
lastError?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MigrationRun = {
|
||||||
|
id: number;
|
||||||
|
runKey: string;
|
||||||
|
knowledgeId?: number;
|
||||||
|
riskScope: string;
|
||||||
|
dryRun: boolean;
|
||||||
|
batchSize: number;
|
||||||
|
cursorAttachmentId: number;
|
||||||
|
status: string;
|
||||||
|
discoveredCount: number;
|
||||||
|
stagedCount: number;
|
||||||
|
quarantinedCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
manifestSha256: string;
|
||||||
|
verifiedDryRunId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerationBuild = {
|
||||||
|
knowledgeId: number;
|
||||||
|
generation: number;
|
||||||
|
collection: string;
|
||||||
|
status: string;
|
||||||
|
expectedVersions: number;
|
||||||
|
includedVersions: number;
|
||||||
|
outstandingJobs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActivationGate = {
|
||||||
|
gateId: number;
|
||||||
|
knowledgeId: number;
|
||||||
|
candidateGeneration: number;
|
||||||
|
status: 'PASSED' | 'BLOCKED';
|
||||||
|
sampleCount: number;
|
||||||
|
top1AgreementRate: number;
|
||||||
|
overlapAt5Avg: number;
|
||||||
|
governedEmptyRate: number;
|
||||||
|
evidence: { blockingAssets: number; openCriticalIssues: number; deadLetters: number; generationStatus: string };
|
||||||
|
};
|
||||||
|
|
||||||
export type KnowledgeGlossaryTerm = {
|
export type KnowledgeGlossaryTerm = {
|
||||||
id: number;
|
id: number;
|
||||||
term: string;
|
term: string;
|
||||||
@@ -601,6 +724,111 @@ export function previewLegacyQualityAssets(limit = 50, afterAttachmentId = 0): P
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listKnowledgeRolloutScopes(): Promise<ApiResult<KnowledgeRolloutScope[]>> {
|
||||||
|
return request({
|
||||||
|
url: '/api/knowledge/quality/rollout/scopes',
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transitionKnowledgeRollout(
|
||||||
|
knowledgeId: number,
|
||||||
|
targetMode: KnowledgeRolloutMode,
|
||||||
|
reason: string,
|
||||||
|
activeGeneration?: number
|
||||||
|
): Promise<ApiResult<KnowledgeRolloutScope>> {
|
||||||
|
return request({
|
||||||
|
url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/transition`,
|
||||||
|
method: 'post',
|
||||||
|
data: { targetMode, reason, activeGeneration },
|
||||||
|
headers: { repeatSubmit: false }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAssetVersions(assetId: number): Promise<ApiResult<KnowledgeVersion[]>> {
|
||||||
|
return request({ url: `/api/knowledge/quality/assets/${assetId}/versions`, method: 'get' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAssetReviewPackage(assetId: number): Promise<ApiResult<ReviewPackage>> {
|
||||||
|
return request({ url: `/api/knowledge/quality/assets/${assetId}/review-package`, method: 'get' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDeletionImpact(assetId: number): Promise<ApiResult<DeletionImpact>> {
|
||||||
|
return request({ url: `/api/knowledge/quality/assets/${assetId}/impact`, method: 'get' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPurgeRequest(assetId: number, reason: string): Promise<ApiResult<PurgeRequest>> {
|
||||||
|
return request({
|
||||||
|
url: `/api/knowledge/quality/assets/${assetId}/purge-requests`,
|
||||||
|
method: 'post',
|
||||||
|
data: { requestKey: `purge:${assetId}:${Date.now()}`, reason },
|
||||||
|
headers: { repeatSubmit: false }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPurgeRequests(status = '', limit = 100): Promise<ApiResult<PurgeRequest[]>> {
|
||||||
|
return request({ url: '/api/knowledge/quality/purge-requests', method: 'get', params: { status, limit } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decidePurgeRequest(id: number, decision: 'APPROVE' | 'REJECT', reason: string): Promise<ApiResult<PurgeRequest>> {
|
||||||
|
return request({ url: `/api/knowledge/quality/purge-requests/${id}/decision`, method: 'post', data: { decision, reason } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executePurgeRequest(id: number): Promise<ApiResult<PurgeRequest>> {
|
||||||
|
return request({ url: `/api/knowledge/quality/purge-requests/${id}/execute`, method: 'post' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runKnowledgeReconciliation(): Promise<ApiResult<{ runId: number; status: string; issues: unknown[] }>> {
|
||||||
|
return request({ url: '/api/knowledge/quality/reconciliation-runs', method: 'post' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listMigrationRuns(limit = 50): Promise<ApiResult<MigrationRun[]>> {
|
||||||
|
return request({ url: '/api/knowledge/quality/migration-runs', method: 'get', params: { limit } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMigrationRun(data: {
|
||||||
|
runKey: string;
|
||||||
|
knowledgeId?: number;
|
||||||
|
riskScope: string;
|
||||||
|
dryRun: boolean;
|
||||||
|
batchSize: number;
|
||||||
|
verifiedDryRunId?: number;
|
||||||
|
}): Promise<ApiResult<MigrationRun>> {
|
||||||
|
return request({ url: '/api/knowledge/quality/migration-runs', method: 'post', data, headers: { repeatSubmit: false } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runNextMigrationBatch(id: number): Promise<ApiResult<{ moreAvailable: boolean; failed: number }>> {
|
||||||
|
return request({ url: `/api/knowledge/quality/migration-runs/${id}/next`, method: 'post', headers: { repeatSubmit: false } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startGenerationBuild(knowledgeId: number, reason: string): Promise<ApiResult<GenerationBuild>> {
|
||||||
|
return request({ url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/generation-builds`, method: 'post', data: { reason } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshGenerationBuild(knowledgeId: number): Promise<ApiResult<GenerationBuild>> {
|
||||||
|
return request({ url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/generation-builds/refresh`, method: 'post' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareShadowQuery(knowledgeId: number, queryText: string): Promise<ApiResult<unknown>> {
|
||||||
|
return request({
|
||||||
|
url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/shadow-comparisons`,
|
||||||
|
method: 'post',
|
||||||
|
data: { queryText, source: 'ADMIN_WORKBENCH' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateActivationGate(knowledgeId: number): Promise<ApiResult<ActivationGate>> {
|
||||||
|
return request({ url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/activation-gates`, method: 'post' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activateGeneration(knowledgeId: number, gateId: number, reason: string): Promise<ApiResult<unknown>> {
|
||||||
|
return request({ url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/activate`, method: 'post', data: { gateId, reason } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rollbackGeneration(knowledgeId: number, reason: string): Promise<ApiResult<unknown>> {
|
||||||
|
return request({ url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/generation-rollback`, method: 'post', data: { reason } });
|
||||||
|
}
|
||||||
|
|
||||||
export function listKnowledgeGlossary(status: KnowledgeGlossaryTerm['status'] = 'ACTIVE', limit = 200): Promise<ApiResult<KnowledgeGlossaryTerm[]>> {
|
export function listKnowledgeGlossary(status: KnowledgeGlossaryTerm['status'] = 'ACTIVE', limit = 200): Promise<ApiResult<KnowledgeGlossaryTerm[]>> {
|
||||||
return request({
|
return request({
|
||||||
url: '/api/knowledge/quality/glossary',
|
url: '/api/knowledge/quality/glossary',
|
||||||
|
|||||||
@@ -256,6 +256,57 @@
|
|||||||
<el-button :icon="Refresh" :loading="qualityLoading" @click="loadQualityAssets">刷新</el-button>
|
<el-button :icon="Refresh" :loading="qualityLoading" @click="loadQualityAssets">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rollout-workbench">
|
||||||
|
<div class="rollout-head">
|
||||||
|
<div>
|
||||||
|
<strong>空间放量</strong>
|
||||||
|
<span>旧链路、影子验证和治理链路按空间独立切换</span>
|
||||||
|
</div>
|
||||||
|
<el-button :icon="Refresh" :loading="rolloutLoading" circle title="刷新空间放量状态" @click="loadRolloutScopes" />
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="rolloutLoading" :data="rolloutScopes" size="small" empty-text="暂无可管理知识空间">
|
||||||
|
<el-table-column prop="spaceName" label="知识空间" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column label="模式" width="104">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="rolloutModeType(row.mode)" size="small">{{ rolloutModeLabel(row.mode) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="资料覆盖" min-width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ row.readyAttachments - row.enforcementBlockers }} / {{ row.readyAttachments }}</span>
|
||||||
|
<el-tag v-if="row.enforcementBlockers" class="rollout-blocker" size="small" type="warning"> 阻断 {{ row.enforcementBlockers }} </el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="向量代次" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
G{{ row.activeGeneration }}
|
||||||
|
<el-tag v-if="row.candidateGeneration" size="small" type="warning">候选 G{{ row.candidateGeneration }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="reason" label="最近变更原因" min-width="210" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="230" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-if="row.mode === 'LEGACY'"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
:loading="rolloutTransitioningId === row.knowledgeId"
|
||||||
|
@click="transitionRollout(row, 'SHADOW')"
|
||||||
|
>进入影子</el-button
|
||||||
|
>
|
||||||
|
<template v-else-if="row.mode === 'SHADOW'">
|
||||||
|
<el-button link :loading="rolloutTransitioningId === row.knowledgeId" @click="transitionRollout(row, 'LEGACY')">退回旧链路</el-button>
|
||||||
|
<el-button link type="success" :loading="rolloutTransitioningId === row.knowledgeId" @click="openRolloutAssistant(row)"
|
||||||
|
>验证与启用</el-button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
<el-button v-else link type="warning" :loading="rolloutTransitioningId === row.knowledgeId" @click="rollbackActiveGeneration(row)"
|
||||||
|
>回退上一代</el-button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
<div v-if="indexHealth" class="index-health" :class="{ unhealthy: !indexHealth.healthy }">
|
<div v-if="indexHealth" class="index-health" :class="{ unhealthy: !indexHealth.healthy }">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ indexHealth.healthy ? '索引一致' : '索引需处理' }}</strong>
|
<strong>{{ indexHealth.healthy ? '索引一致' : '索引需处理' }}</strong>
|
||||||
@@ -340,6 +391,74 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="governance-operations">
|
||||||
|
<div class="rollout-head">
|
||||||
|
<div>
|
||||||
|
<strong>迁移与清理任务</strong>
|
||||||
|
<span>历史补链默认先演练;物理清理必须经过异人审批和保留期。</span>
|
||||||
|
</div>
|
||||||
|
<div class="quality-actions">
|
||||||
|
<el-button :loading="governanceLoading" @click="createMigrationPlan">新建迁移演练</el-button>
|
||||||
|
<el-button :loading="governanceLoading" @click="reconcileKnowledgeStores">运行存储对账</el-button>
|
||||||
|
<el-button :icon="Refresh" circle title="刷新治理任务" @click="loadGovernanceOperations" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-tabs>
|
||||||
|
<el-tab-pane label="历史迁移">
|
||||||
|
<el-table :data="migrationRuns" size="small" empty-text="暂无迁移任务">
|
||||||
|
<el-table-column prop="runKey" label="任务" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column label="模式" width="90"
|
||||||
|
><template #default="{ row }">{{ row.dryRun ? '演练' : '执行' }}</template></el-table-column
|
||||||
|
>
|
||||||
|
<el-table-column prop="status" label="状态" width="150" />
|
||||||
|
<el-table-column label="进度" min-width="190">
|
||||||
|
<template #default="{ row }"
|
||||||
|
>发现 {{ row.discoveredCount }} / 纳管 {{ row.stagedCount }} / 隔离 {{ row.quarantinedCount }} / 失败
|
||||||
|
{{ row.failedCount }}</template
|
||||||
|
>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="190" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-if="!['COMPLETED', 'COMPLETED_WITH_ERRORS', 'CANCELLED'].includes(row.status)"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="advanceMigration(row)"
|
||||||
|
>执行下一批</el-button
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
v-if="row.dryRun && row.status === 'COMPLETED' && row.failedCount === 0"
|
||||||
|
link
|
||||||
|
type="success"
|
||||||
|
@click="createFormalMigration(row)"
|
||||||
|
>生成正式任务</el-button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="清理审批">
|
||||||
|
<el-table :data="purgeRequests" size="small" empty-text="暂无清理申请">
|
||||||
|
<el-table-column prop="assetId" label="资产" width="90" />
|
||||||
|
<el-table-column prop="retentionClass" label="保留策略" width="110" />
|
||||||
|
<el-table-column prop="status" label="状态" width="120" />
|
||||||
|
<el-table-column prop="reason" label="申请原因" min-width="220" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="executeAfter" label="最早执行时间" width="170" />
|
||||||
|
<el-table-column prop="lastError" label="异常" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="190" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="row.status === 'PENDING'">
|
||||||
|
<el-button link type="success" @click="reviewPurge(row, 'APPROVE')">批准</el-button>
|
||||||
|
<el-button link type="danger" @click="reviewPurge(row, 'REJECT')">驳回</el-button>
|
||||||
|
</template>
|
||||||
|
<el-button v-if="['APPROVED', 'FAILED'].includes(row.status)" link type="danger" @click="executePurge(row)">执行清理</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel event-panel">
|
<section class="panel event-panel">
|
||||||
@@ -781,6 +900,51 @@
|
|||||||
<el-descriptions-item label="重试次数">{{ selectedQualityAsset.semanticRetryCount }}</el-descriptions-item>
|
<el-descriptions-item label="重试次数">{{ selectedQualityAsset.semanticRetryCount }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
|
<section class="asset-governance-summary">
|
||||||
|
<div class="privacy-preview-head">
|
||||||
|
<h3>版本与审核辅助</h3>
|
||||||
|
<el-tag
|
||||||
|
v-if="reviewPackage"
|
||||||
|
:type="reviewPackage.riskLevel === 'LOW' ? 'success' : reviewPackage.riskLevel === 'MEDIUM' ? 'warning' : 'danger'"
|
||||||
|
>
|
||||||
|
{{ reviewPackage.riskLevel }} 风险
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="reviewPackage" class="review-assistant-grid">
|
||||||
|
<span>候选 v{{ reviewPackage.versionNo }}</span>
|
||||||
|
<span>{{ reviewPackage.summary.chunkCount }} 个片段</span>
|
||||||
|
<span>{{ reviewPackage.summary.duplicateCount }} 组重复</span>
|
||||||
|
<span>{{ reviewPackage.summary.conflictCount }} 项冲突</span>
|
||||||
|
</div>
|
||||||
|
<el-table :data="assetVersions" size="small" max-height="190" empty-text="暂无版本记录">
|
||||||
|
<el-table-column label="版本" width="86"
|
||||||
|
><template #default="{ row }">v{{ row.versionNo }}</template></el-table-column
|
||||||
|
>
|
||||||
|
<el-table-column prop="versionStatus" label="状态" width="130" />
|
||||||
|
<el-table-column label="指针" width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.published" size="small" type="success">生效版</el-tag>
|
||||||
|
<el-tag v-if="row.candidate" size="small" type="warning">候选版</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="chunkCount" label="片段" width="80" />
|
||||||
|
<el-table-column prop="createTime" label="创建时间" min-width="170" />
|
||||||
|
</el-table>
|
||||||
|
<el-alert
|
||||||
|
v-if="deletionImpact"
|
||||||
|
:title="`清理影响:${deletionImpact.publishedFragments} 个生产片段、${deletionImpact.queryEvidence} 条问答证据、${deletionImpact.generations} 个向量代次`"
|
||||||
|
:type="deletionImpact.canRequestPurge ? 'warning' : 'info'"
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
|
<el-button
|
||||||
|
v-if="selectedQualityAsset.lifecycleStatus === 'DEPRECATED' && deletionImpact?.canRequestPurge"
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
@click="requestAssetPurge(selectedQualityAsset)"
|
||||||
|
>申请物理清理</el-button
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="selectedQualityAsset.semanticAnalysisStatus === 'FAILED' || selectedQualityAsset.semanticAnalysisStatus === 'DEAD_LETTER'"
|
v-if="selectedQualityAsset.semanticAnalysisStatus === 'FAILED' || selectedQualityAsset.semanticAnalysisStatus === 'DEAD_LETTER'"
|
||||||
:title="selectedQualityAsset.semanticLastError || '语义分析失败,当前版本不能发布'"
|
:title="selectedQualityAsset.semanticLastError || '语义分析失败,当前版本不能发布'"
|
||||||
@@ -970,6 +1134,43 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="rolloutDialogVisible" title="影子验证与代次启用" width="min(760px, 94vw)" destroy-on-close>
|
||||||
|
<template v-if="selectedRolloutScope">
|
||||||
|
<el-descriptions :column="2" border size="small">
|
||||||
|
<el-descriptions-item label="知识空间">{{ selectedRolloutScope.spaceName }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="当前代次">G{{ selectedRolloutScope.activeGeneration }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="候选代次">{{
|
||||||
|
selectedRolloutScope.candidateGeneration ? `G${selectedRolloutScope.candidateGeneration}` : '尚未构建'
|
||||||
|
}}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="阻断资料">{{ selectedRolloutScope.enforcementBlockers }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<div class="rollout-assistant-actions">
|
||||||
|
<el-button :loading="rolloutAssisting" @click="buildCandidateGeneration">构建候选代次</el-button>
|
||||||
|
<el-button :loading="rolloutAssisting" @click="refreshCandidateGeneration">刷新构建状态</el-button>
|
||||||
|
</div>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="影子验证问题">
|
||||||
|
<el-input v-model="rolloutQuery" maxlength="1000" placeholder="输入一条真实业务问题进行旧链路与治理链路对照" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-button :disabled="!rolloutQuery.trim()" :loading="rolloutAssisting" @click="runShadowComparison">记录对照样本</el-button>
|
||||||
|
<el-button :loading="rolloutAssisting" @click="checkActivationGate">评估启用门禁</el-button>
|
||||||
|
</el-form>
|
||||||
|
<el-alert
|
||||||
|
v-if="rolloutGate"
|
||||||
|
:title="`门禁 ${rolloutGate.status}:样本 ${rolloutGate.sampleCount},Top1 一致率 ${formatPercent(rolloutGate.top1AgreementRate)},Top5 重合率 ${formatPercent(rolloutGate.overlapAt5Avg)}`"
|
||||||
|
:type="rolloutGate.status === 'PASSED' ? 'success' : 'warning'"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="rolloutDialogVisible = false">关闭</el-button>
|
||||||
|
<el-button v-if="rolloutGate?.status === 'PASSED'" type="success" :loading="rolloutAssisting" @click="activateCandidateGeneration"
|
||||||
|
>确认启用候选代次</el-button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="glossaryDialogVisible"
|
v-model="glossaryDialogVisible"
|
||||||
:title="glossaryRevisionSourceId ? '创建术语新修订' : '新建业务术语'"
|
:title="glossaryRevisionSourceId ? '创建术语新修订' : '新建业务术语'"
|
||||||
@@ -1016,15 +1217,24 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
|
|||||||
import {
|
import {
|
||||||
addGoldenSample,
|
addGoldenSample,
|
||||||
approveKnowledgeGlossary,
|
approveKnowledgeGlossary,
|
||||||
|
activateGeneration,
|
||||||
|
compareShadowQuery,
|
||||||
createAcceptanceProfile,
|
createAcceptanceProfile,
|
||||||
|
createMigrationRun,
|
||||||
|
createPurgeRequest,
|
||||||
|
decidePurgeRequest,
|
||||||
|
executePurgeRequest,
|
||||||
createGoldenDatasetDraft,
|
createGoldenDatasetDraft,
|
||||||
createKnowledgeGlossaryDraft,
|
createKnowledgeGlossaryDraft,
|
||||||
createProcessingRuleDraft,
|
createProcessingRuleDraft,
|
||||||
deprecateKnowledgeGlossary,
|
deprecateKnowledgeGlossary,
|
||||||
freezeAcceptanceProfile,
|
freezeAcceptanceProfile,
|
||||||
freezeGoldenDataset,
|
freezeGoldenDataset,
|
||||||
|
getAssetReviewPackage,
|
||||||
|
getDeletionImpact,
|
||||||
getProcessingRuleMetrics,
|
getProcessingRuleMetrics,
|
||||||
getRuleReadiness,
|
getRuleReadiness,
|
||||||
|
evaluateActivationGate,
|
||||||
getQualityMetrics,
|
getQualityMetrics,
|
||||||
getQualityIndexHealth,
|
getQualityIndexHealth,
|
||||||
getQualityPrivacyPreview,
|
getQualityPrivacyPreview,
|
||||||
@@ -1034,10 +1244,14 @@ import {
|
|||||||
listQualityIssues,
|
listQualityIssues,
|
||||||
listQualityAlerts,
|
listQualityAlerts,
|
||||||
listKnowledgeGlossary,
|
listKnowledgeGlossary,
|
||||||
|
listAssetVersions,
|
||||||
listAcceptanceProfiles,
|
listAcceptanceProfiles,
|
||||||
listGoldenDatasets,
|
listGoldenDatasets,
|
||||||
listGoldenSamples,
|
listGoldenSamples,
|
||||||
listPipelineRuns,
|
listPipelineRuns,
|
||||||
|
listKnowledgeRolloutScopes,
|
||||||
|
listMigrationRuns,
|
||||||
|
listPurgeRequests,
|
||||||
listProcessingRules,
|
listProcessingRules,
|
||||||
listRuleReviewSamples,
|
listRuleReviewSamples,
|
||||||
previewLegacyQualityAssets,
|
previewLegacyQualityAssets,
|
||||||
@@ -1045,10 +1259,16 @@ import {
|
|||||||
rejectKnowledgeGlossary,
|
rejectKnowledgeGlossary,
|
||||||
reviewRuleSample,
|
reviewRuleSample,
|
||||||
reviewQualityConflict,
|
reviewQualityConflict,
|
||||||
|
rollbackGeneration,
|
||||||
|
runKnowledgeReconciliation,
|
||||||
|
runNextMigrationBatch,
|
||||||
retryProcessingTask,
|
retryProcessingTask,
|
||||||
stageLegacyQualityAssets,
|
stageLegacyQualityAssets,
|
||||||
scheduleRuleReviewSamples,
|
scheduleRuleReviewSamples,
|
||||||
transitionProcessingRule,
|
transitionProcessingRule,
|
||||||
|
transitionKnowledgeRollout,
|
||||||
|
startGenerationBuild,
|
||||||
|
refreshGenerationBuild,
|
||||||
withdrawQualityAsset,
|
withdrawQualityAsset,
|
||||||
type ProcessingOverview,
|
type ProcessingOverview,
|
||||||
type ProcessingTask,
|
type ProcessingTask,
|
||||||
@@ -1060,7 +1280,15 @@ import {
|
|||||||
type QualityIssue,
|
type QualityIssue,
|
||||||
type QualityPrivacyPreview,
|
type QualityPrivacyPreview,
|
||||||
type QualityReviewRequest,
|
type QualityReviewRequest,
|
||||||
|
type ReviewPackage,
|
||||||
|
type KnowledgeVersion,
|
||||||
|
type DeletionImpact,
|
||||||
|
type PurgeRequest,
|
||||||
|
type MigrationRun,
|
||||||
|
type ActivationGate,
|
||||||
type KnowledgeGlossaryTerm,
|
type KnowledgeGlossaryTerm,
|
||||||
|
type KnowledgeRolloutMode,
|
||||||
|
type KnowledgeRolloutScope,
|
||||||
type AcceptanceProfile,
|
type AcceptanceProfile,
|
||||||
type AcceptanceProfileDraft,
|
type AcceptanceProfileDraft,
|
||||||
type GoldenDataset,
|
type GoldenDataset,
|
||||||
@@ -1078,7 +1306,8 @@ import { uploadKnowledgeDocAsync, listUploadItems, retryUploadItem, type UploadI
|
|||||||
import { listKnowledgeSpaces, type KnowledgeSpace } from '@/api/aihr/space';
|
import { listKnowledgeSpaces, type KnowledgeSpace } from '@/api/aihr/space';
|
||||||
import { listPersonalPublishRequests, reviewPersonalPublishRequest, type PersonalPublishRequestRow } from '@/api/aihr/personal';
|
import { listPersonalPublishRequests, reviewPersonalPublishRequest, type PersonalPublishRequestRow } from '@/api/aihr/personal';
|
||||||
|
|
||||||
const acceptTypes = '.txt,.md,.markdown,.rtf,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.avi,.mkv,.webm,.m4v,.zip';
|
const acceptTypes =
|
||||||
|
'.txt,.md,.markdown,.rtf,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.avi,.mkv,.webm,.m4v,.zip';
|
||||||
const supportedPattern = /\.(txt|md|markdown|rtf|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|webp|bmp|mp4|mov|avi|mkv|webm|m4v|zip)$/i;
|
const supportedPattern = /\.(txt|md|markdown|rtf|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|webp|bmp|mp4|mov|avi|mkv|webm|m4v|zip)$/i;
|
||||||
const maxFileSize = 500 * 1024 * 1024;
|
const maxFileSize = 500 * 1024 * 1024;
|
||||||
const browserBatchWarnCount = 20;
|
const browserBatchWarnCount = 20;
|
||||||
@@ -1103,6 +1332,20 @@ const indexHealth = ref<QualityIndexHealth | null>(null);
|
|||||||
const indexHealthFailed = ref(false);
|
const indexHealthFailed = ref(false);
|
||||||
const qualityMetrics = ref<QualitySnapshot | null>(null);
|
const qualityMetrics = ref<QualitySnapshot | null>(null);
|
||||||
const qualityAlerts = ref<QualityAlert[]>([]);
|
const qualityAlerts = ref<QualityAlert[]>([]);
|
||||||
|
const rolloutLoading = ref(false);
|
||||||
|
const rolloutTransitioningId = ref<number | null>(null);
|
||||||
|
const rolloutScopes = ref<KnowledgeRolloutScope[]>([]);
|
||||||
|
const rolloutDialogVisible = ref(false);
|
||||||
|
const selectedRolloutScope = ref<KnowledgeRolloutScope | null>(null);
|
||||||
|
const rolloutGate = ref<ActivationGate | null>(null);
|
||||||
|
const rolloutQuery = ref('');
|
||||||
|
const rolloutAssisting = ref(false);
|
||||||
|
const governanceLoading = ref(false);
|
||||||
|
const migrationRuns = ref<MigrationRun[]>([]);
|
||||||
|
const purgeRequests = ref<PurgeRequest[]>([]);
|
||||||
|
const assetVersions = ref<KnowledgeVersion[]>([]);
|
||||||
|
const reviewPackage = ref<ReviewPackage | null>(null);
|
||||||
|
const deletionImpact = ref<DeletionImpact | null>(null);
|
||||||
const glossaryLoading = ref(false);
|
const glossaryLoading = ref(false);
|
||||||
const glossarySaving = ref(false);
|
const glossarySaving = ref(false);
|
||||||
const glossaryStatus = ref<KnowledgeGlossaryTerm['status']>('ACTIVE');
|
const glossaryStatus = ref<KnowledgeGlossaryTerm['status']>('ACTIVE');
|
||||||
@@ -1424,6 +1667,8 @@ onMounted(async () => {
|
|||||||
loadQualityAssets(),
|
loadQualityAssets(),
|
||||||
loadIndexHealth(),
|
loadIndexHealth(),
|
||||||
loadQualityMonitoring(),
|
loadQualityMonitoring(),
|
||||||
|
loadRolloutScopes(),
|
||||||
|
loadGovernanceOperations(),
|
||||||
loadGlossary(),
|
loadGlossary(),
|
||||||
loadRuleWorkbench(),
|
loadRuleWorkbench(),
|
||||||
loadGoldenDatasets()
|
loadGoldenDatasets()
|
||||||
@@ -1462,6 +1707,284 @@ async function loadQualityAssets() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadRolloutScopes() {
|
||||||
|
rolloutLoading.value = true;
|
||||||
|
try {
|
||||||
|
rolloutScopes.value = (await listKnowledgeRolloutScopes()).data || [];
|
||||||
|
} catch {
|
||||||
|
rolloutScopes.value = [];
|
||||||
|
} finally {
|
||||||
|
rolloutLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transitionRollout(scope: KnowledgeRolloutScope, targetMode: KnowledgeRolloutMode) {
|
||||||
|
const targetLabel = rolloutModeLabel(targetMode);
|
||||||
|
try {
|
||||||
|
const { value } = await ElMessageBox.prompt(
|
||||||
|
targetMode === 'ENFORCED'
|
||||||
|
? '启用后该空间只召回已审核发布且索引就绪的治理版本。'
|
||||||
|
: targetMode === 'LEGACY'
|
||||||
|
? '回退后该空间立即继续使用原有资料与旧向量。'
|
||||||
|
: '影子模式继续服务旧结果,同时允许生成治理链路对照数据。',
|
||||||
|
`切换到${targetLabel}`,
|
||||||
|
{
|
||||||
|
confirmButtonText: '确认切换',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputType: 'textarea',
|
||||||
|
inputPlaceholder: '填写切换原因',
|
||||||
|
inputValidator: (input) => Boolean(input?.trim()) || '请填写切换原因'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
rolloutTransitioningId.value = scope.knowledgeId;
|
||||||
|
await transitionKnowledgeRollout(scope.knowledgeId, targetMode, value.trim(), scope.activeGeneration);
|
||||||
|
await loadRolloutScopes();
|
||||||
|
ElMessage.success(`${scope.spaceName}已切换到${targetLabel}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel' && error !== 'close') ElMessage.error('空间模式切换失败,请检查覆盖率与索引状态');
|
||||||
|
} finally {
|
||||||
|
rolloutTransitioningId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGovernanceOperations() {
|
||||||
|
governanceLoading.value = true;
|
||||||
|
try {
|
||||||
|
const [migrations, purges] = await Promise.all([listMigrationRuns(), listPurgeRequests()]);
|
||||||
|
migrationRuns.value = migrations.data || [];
|
||||||
|
purgeRequests.value = purges.data || [];
|
||||||
|
} catch {
|
||||||
|
migrationRuns.value = [];
|
||||||
|
purgeRequests.value = [];
|
||||||
|
} finally {
|
||||||
|
governanceLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMigrationPlan() {
|
||||||
|
try {
|
||||||
|
const { value } = await ElMessageBox.prompt('可选:输入知识空间 ID;留空则覆盖当前租户全部空间。', '新建历史迁移演练', {
|
||||||
|
confirmButtonText: '创建演练',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputPlaceholder: '知识空间 ID(可留空)',
|
||||||
|
inputValidator: (input) => !input?.trim() || /^\d+$/.test(input.trim()) || '请输入数字 ID'
|
||||||
|
});
|
||||||
|
const knowledgeId = value?.trim() ? Number(value.trim()) : undefined;
|
||||||
|
await createMigrationRun({
|
||||||
|
runKey: `admin-dry-run:${knowledgeId || 'all'}:${Date.now()}`,
|
||||||
|
knowledgeId,
|
||||||
|
riskScope: 'ALL',
|
||||||
|
dryRun: true,
|
||||||
|
batchSize: 50
|
||||||
|
});
|
||||||
|
await loadGovernanceOperations();
|
||||||
|
ElMessage.success('迁移演练清单已创建,尚未修改任何资料');
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel' && error !== 'close') ElMessage.error('迁移演练创建失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function advanceMigration(run: MigrationRun) {
|
||||||
|
governanceLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = (await runNextMigrationBatch(run.id)).data;
|
||||||
|
await loadGovernanceOperations();
|
||||||
|
ElMessage.success(result.moreAvailable ? '本批完成,可继续下一批' : '迁移任务已完成');
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('迁移批次执行失败,任务游标未丢失');
|
||||||
|
} finally {
|
||||||
|
governanceLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFormalMigration(dryRun: MigrationRun) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`将依据演练 #${dryRun.id} 生成同范围正式迁移任务。创建后不会立即改动资料,仍需人工逐批执行。`, '确认生成正式任务', {
|
||||||
|
confirmButtonText: '生成正式任务',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
});
|
||||||
|
await createMigrationRun({
|
||||||
|
runKey: `admin-formal:${dryRun.knowledgeId || 'all'}:${Date.now()}`,
|
||||||
|
knowledgeId: dryRun.knowledgeId,
|
||||||
|
riskScope: dryRun.riskScope,
|
||||||
|
dryRun: false,
|
||||||
|
batchSize: dryRun.batchSize,
|
||||||
|
verifiedDryRunId: dryRun.id
|
||||||
|
});
|
||||||
|
await loadGovernanceOperations();
|
||||||
|
ElMessage.success('正式迁移任务已创建,尚未执行任何批次');
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel' && error !== 'close') ElMessage.error('正式迁移任务创建失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reconcileKnowledgeStores() {
|
||||||
|
governanceLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = (await runKnowledgeReconciliation()).data;
|
||||||
|
ElMessage[result.status === 'PASSED' ? 'success' : 'warning'](
|
||||||
|
result.status === 'PASSED' ? 'MySQL、OSS 与向量血缘对账通过' : `对账发现 ${result.issues.length} 项待处理问题`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('存储对账执行失败');
|
||||||
|
} finally {
|
||||||
|
governanceLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reviewPurge(request: PurgeRequest, decision: 'APPROVE' | 'REJECT') {
|
||||||
|
try {
|
||||||
|
const { value } = await ElMessageBox.prompt(
|
||||||
|
decision === 'APPROVE' ? '批准人必须与申请人不同;批准后仍需等待保留期。' : '请说明驳回依据。',
|
||||||
|
decision === 'APPROVE' ? '批准清理申请' : '驳回清理申请',
|
||||||
|
{
|
||||||
|
confirmButtonText: decision === 'APPROVE' ? '确认批准' : '确认驳回',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputType: 'textarea',
|
||||||
|
inputValidator: (input) => Boolean(input?.trim()) || '请填写审核依据'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await decidePurgeRequest(request.id, decision, value.trim());
|
||||||
|
await loadGovernanceOperations();
|
||||||
|
ElMessage.success(decision === 'APPROVE' ? '清理申请已批准' : '清理申请已驳回');
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel' && error !== 'close') ElMessage.error('清理申请审核失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executePurge(request: PurgeRequest) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`将不可恢复地清理资产 #${request.assetId} 的 MySQL、向量与独占 OSS 数据。系统仍会校验保留期和异人控制。`,
|
||||||
|
'执行物理清理',
|
||||||
|
{ confirmButtonText: '确认执行', cancelButtonText: '取消', type: 'error' }
|
||||||
|
);
|
||||||
|
await executePurgeRequest(request.id);
|
||||||
|
await Promise.all([loadGovernanceOperations(), loadQualityAssets(), loadIndexHealth()]);
|
||||||
|
ElMessage.success('物理清理已完成并保留审计墓碑');
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel' && error !== 'close') ElMessage.error('物理清理未执行,请检查保留期、审批人和存储状态');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRolloutAssistant(scope: KnowledgeRolloutScope) {
|
||||||
|
selectedRolloutScope.value = scope;
|
||||||
|
rolloutGate.value = null;
|
||||||
|
rolloutQuery.value = '';
|
||||||
|
rolloutDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildCandidateGeneration() {
|
||||||
|
const scope = selectedRolloutScope.value;
|
||||||
|
if (!scope) return;
|
||||||
|
rolloutAssisting.value = true;
|
||||||
|
try {
|
||||||
|
await startGenerationBuild(scope.knowledgeId, '管理端启动候选代次全量构建');
|
||||||
|
await loadRolloutScopes();
|
||||||
|
selectedRolloutScope.value = rolloutScopes.value.find((item) => item.knowledgeId === scope.knowledgeId) || scope;
|
||||||
|
ElMessage.success('候选代次已排队构建');
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('候选代次构建启动失败');
|
||||||
|
} finally {
|
||||||
|
rolloutAssisting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCandidateGeneration() {
|
||||||
|
const scope = selectedRolloutScope.value;
|
||||||
|
if (!scope) return;
|
||||||
|
rolloutAssisting.value = true;
|
||||||
|
try {
|
||||||
|
const result = (await refreshGenerationBuild(scope.knowledgeId)).data;
|
||||||
|
ElMessage.info(`候选 G${result.generation}:${result.status},已纳入 ${result.includedVersions}/${result.expectedVersions}`);
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('候选代次状态刷新失败');
|
||||||
|
} finally {
|
||||||
|
rolloutAssisting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runShadowComparison() {
|
||||||
|
const scope = selectedRolloutScope.value;
|
||||||
|
if (!scope || !rolloutQuery.value.trim()) return;
|
||||||
|
rolloutAssisting.value = true;
|
||||||
|
try {
|
||||||
|
await compareShadowQuery(scope.knowledgeId, rolloutQuery.value.trim());
|
||||||
|
rolloutQuery.value = '';
|
||||||
|
ElMessage.success('影子对照样本已记录,不影响当前在线结果');
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('影子对照失败,请确认候选代次已创建');
|
||||||
|
} finally {
|
||||||
|
rolloutAssisting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkActivationGate() {
|
||||||
|
const scope = selectedRolloutScope.value;
|
||||||
|
if (!scope) return;
|
||||||
|
rolloutAssisting.value = true;
|
||||||
|
try {
|
||||||
|
rolloutGate.value = (await evaluateActivationGate(scope.knowledgeId)).data;
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('启用门禁评估失败');
|
||||||
|
} finally {
|
||||||
|
rolloutAssisting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateCandidateGeneration() {
|
||||||
|
const scope = selectedRolloutScope.value;
|
||||||
|
const gate = rolloutGate.value;
|
||||||
|
if (!scope || !gate || gate.status !== 'PASSED') return;
|
||||||
|
try {
|
||||||
|
const { value } = await ElMessageBox.prompt('启用后员工检索将切换到候选代次,七天内可回退。', '确认启用治理链路', {
|
||||||
|
confirmButtonText: '确认启用',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputType: 'textarea',
|
||||||
|
inputPlaceholder: '填写启用原因',
|
||||||
|
inputValidator: (input) => Boolean(input?.trim()) || '请填写启用原因'
|
||||||
|
});
|
||||||
|
rolloutAssisting.value = true;
|
||||||
|
await activateGeneration(scope.knowledgeId, gate.gateId, value.trim());
|
||||||
|
rolloutDialogVisible.value = false;
|
||||||
|
await loadRolloutScopes();
|
||||||
|
ElMessage.success('候选代次已启用,回退窗口为七天');
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel' && error !== 'close') ElMessage.error('候选代次启用失败');
|
||||||
|
} finally {
|
||||||
|
rolloutAssisting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rollbackActiveGeneration(scope: KnowledgeRolloutScope) {
|
||||||
|
try {
|
||||||
|
const { value } = await ElMessageBox.prompt('仅能在最近一次启用后的七天窗口内回退。', '回退向量代次', {
|
||||||
|
confirmButtonText: '确认回退',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputType: 'textarea',
|
||||||
|
inputPlaceholder: '填写回退原因',
|
||||||
|
inputValidator: (input) => Boolean(input?.trim()) || '请填写回退原因'
|
||||||
|
});
|
||||||
|
rolloutTransitioningId.value = scope.knowledgeId;
|
||||||
|
await rollbackGeneration(scope.knowledgeId, value.trim());
|
||||||
|
await loadRolloutScopes();
|
||||||
|
ElMessage.success('已回退上一代并返回影子模式');
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel' && error !== 'close') ElMessage.error('代次回退失败或已超过回退窗口');
|
||||||
|
} finally {
|
||||||
|
rolloutTransitioningId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rolloutModeLabel(mode: KnowledgeRolloutMode) {
|
||||||
|
return { LEGACY: '旧链路', SHADOW: '影子', ENFORCED: '治理链路' }[mode];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rolloutModeType(mode: KnowledgeRolloutMode) {
|
||||||
|
return ({ LEGACY: 'info', SHADOW: 'warning', ENFORCED: 'success' } as const)[mode];
|
||||||
|
}
|
||||||
|
|
||||||
async function loadGlossary() {
|
async function loadGlossary() {
|
||||||
glossaryLoading.value = true;
|
glossaryLoading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -2059,6 +2582,9 @@ async function openQualityReview(asset: QualityAsset) {
|
|||||||
qualityPrivacyPreview.value = null;
|
qualityPrivacyPreview.value = null;
|
||||||
qualityConflicts.value = [];
|
qualityConflicts.value = [];
|
||||||
publishedQualityAssets.value = [];
|
publishedQualityAssets.value = [];
|
||||||
|
assetVersions.value = [];
|
||||||
|
reviewPackage.value = null;
|
||||||
|
deletionImpact.value = null;
|
||||||
qualityDetailLoading.value = true;
|
qualityDetailLoading.value = true;
|
||||||
qualityDialogVisible.value = true;
|
qualityDialogVisible.value = true;
|
||||||
qualityReviewForm.value = {
|
qualityReviewForm.value = {
|
||||||
@@ -2075,23 +2601,32 @@ async function openQualityReview(asset: QualityAsset) {
|
|||||||
supersedesAssetId: undefined
|
supersedesAssetId: undefined
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const [issues, privacyPreview, conflicts, published] = await Promise.all([
|
const [issues, privacyPreview, conflicts, published, versions, impact, review] = await Promise.all([
|
||||||
listQualityIssues(asset.id),
|
listQualityIssues(asset.id),
|
||||||
getQualityPrivacyPreview(asset.id),
|
getQualityPrivacyPreview(asset.id),
|
||||||
listQualityConflicts(asset.id),
|
listQualityConflicts(asset.id),
|
||||||
listQualityAssets('PUBLISHED', 200)
|
listQualityAssets('PUBLISHED', 200),
|
||||||
|
listAssetVersions(asset.id),
|
||||||
|
getDeletionImpact(asset.id),
|
||||||
|
asset.lifecycleStatus === 'REVIEW_PENDING' ? getAssetReviewPackage(asset.id) : Promise.resolve({ data: null })
|
||||||
]);
|
]);
|
||||||
if (selectedQualityAsset.value?.id !== asset.id) return;
|
if (selectedQualityAsset.value?.id !== asset.id) return;
|
||||||
qualityIssues.value = issues.data || [];
|
qualityIssues.value = issues.data || [];
|
||||||
qualityPrivacyPreview.value = privacyPreview.data || null;
|
qualityPrivacyPreview.value = privacyPreview.data || null;
|
||||||
qualityConflicts.value = conflicts.data || [];
|
qualityConflicts.value = conflicts.data || [];
|
||||||
publishedQualityAssets.value = (published.data || []).filter((item) => item.id !== asset.id);
|
publishedQualityAssets.value = (published.data || []).filter((item) => item.id !== asset.id);
|
||||||
|
assetVersions.value = versions.data || [];
|
||||||
|
deletionImpact.value = impact.data || null;
|
||||||
|
reviewPackage.value = review.data || null;
|
||||||
} catch {
|
} catch {
|
||||||
if (selectedQualityAsset.value?.id !== asset.id) return;
|
if (selectedQualityAsset.value?.id !== asset.id) return;
|
||||||
qualityIssues.value = [];
|
qualityIssues.value = [];
|
||||||
qualityPrivacyPreview.value = null;
|
qualityPrivacyPreview.value = null;
|
||||||
qualityConflicts.value = [];
|
qualityConflicts.value = [];
|
||||||
publishedQualityAssets.value = [];
|
publishedQualityAssets.value = [];
|
||||||
|
assetVersions.value = [];
|
||||||
|
reviewPackage.value = null;
|
||||||
|
deletionImpact.value = null;
|
||||||
ElMessage.error('质量原因加载失败');
|
ElMessage.error('质量原因加载失败');
|
||||||
} finally {
|
} finally {
|
||||||
if (selectedQualityAsset.value?.id === asset.id) qualityDetailLoading.value = false;
|
if (selectedQualityAsset.value?.id === asset.id) qualityDetailLoading.value = false;
|
||||||
@@ -2578,6 +3113,23 @@ function fileBadgeClass(type: string) {
|
|||||||
if (type === '视频') return 'ppt';
|
if (type === '视频') return 'ppt';
|
||||||
return 'text';
|
return 'text';
|
||||||
}
|
}
|
||||||
|
async function requestAssetPurge(asset: QualityAsset) {
|
||||||
|
try {
|
||||||
|
const { value } = await ElMessageBox.prompt('申请不会立即删除。系统将在保留期结束后,要求另一名管理员审批并执行。', '申请物理清理', {
|
||||||
|
confirmButtonText: '提交申请',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputType: 'textarea',
|
||||||
|
inputPlaceholder: '填写必须物理清理的原因',
|
||||||
|
inputValidator: (input) => Boolean(input?.trim()) || '请填写清理原因'
|
||||||
|
});
|
||||||
|
await createPurgeRequest(asset.id, value.trim());
|
||||||
|
qualityDialogVisible.value = false;
|
||||||
|
await loadGovernanceOperations();
|
||||||
|
ElMessage.success('清理申请已提交,等待异人审批和保留期');
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel' && error !== 'close') ElMessage.error('清理申请提交失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -3082,6 +3634,34 @@ function fileBadgeClass(type: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rollout-workbench {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rollout-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 52px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rollout-head > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rollout-head span {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rollout-blocker {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.index-health.unhealthy {
|
.index-health.unhealthy {
|
||||||
border-left-color: #d64545;
|
border-left-color: #d64545;
|
||||||
background: #fff4f4;
|
background: #fff4f4;
|
||||||
@@ -3225,6 +3805,34 @@ function fileBadgeClass(type: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.governance-operations,
|
||||||
|
.asset-governance-summary {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #e4e7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-assistant-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rollout-assistant-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.quality-issue-list {
|
.quality-issue-list {
|
||||||
margin: 18px 0;
|
margin: 18px 0;
|
||||||
|
|
||||||
@@ -3421,7 +4029,8 @@ function fileBadgeClass(type: string) {
|
|||||||
|
|
||||||
.quality-form-grid,
|
.quality-form-grid,
|
||||||
.reason-checks,
|
.reason-checks,
|
||||||
.conflict-sources {
|
.conflict-sources,
|
||||||
|
.review-assistant-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -269,6 +269,24 @@ FROM (
|
|||||||
UNION ALL SELECT 'aihr_personal_ocr_page'
|
UNION ALL SELECT 'aihr_personal_ocr_page'
|
||||||
UNION ALL SELECT 'aihr_personal_export_task'
|
UNION ALL SELECT 'aihr_personal_export_task'
|
||||||
UNION ALL SELECT 'aihr_personal_publish_request'
|
UNION ALL SELECT 'aihr_personal_publish_request'
|
||||||
|
UNION ALL SELECT 'aihr_knowledge_rollout_scope'
|
||||||
|
UNION ALL SELECT 'aihr_knowledge_rollout_transition'
|
||||||
|
UNION ALL SELECT 'aihr_review_assistance'
|
||||||
|
UNION ALL SELECT 'aihr_review_batch'
|
||||||
|
UNION ALL SELECT 'aihr_review_batch_item'
|
||||||
|
UNION ALL SELECT 'aihr_knowledge_generation'
|
||||||
|
UNION ALL SELECT 'aihr_generation_version'
|
||||||
|
UNION ALL SELECT 'aihr_version_diff'
|
||||||
|
UNION ALL SELECT 'aihr_version_rollback'
|
||||||
|
UNION ALL SELECT 'aihr_deletion_request'
|
||||||
|
UNION ALL SELECT 'aihr_reconciliation_run'
|
||||||
|
UNION ALL SELECT 'aihr_reconciliation_issue'
|
||||||
|
UNION ALL SELECT 'aihr_migration_run'
|
||||||
|
UNION ALL SELECT 'aihr_migration_batch'
|
||||||
|
UNION ALL SELECT 'aihr_migration_dead_letter'
|
||||||
|
UNION ALL SELECT 'aihr_shadow_comparison'
|
||||||
|
UNION ALL SELECT 'aihr_activation_gate'
|
||||||
|
UNION ALL SELECT 'aihr_generation_activation'
|
||||||
) required
|
) required
|
||||||
LEFT JOIN information_schema.tables actual
|
LEFT JOIN information_schema.tables actual
|
||||||
ON actual.table_schema = DATABASE() AND actual.table_name = required.table_name
|
ON actual.table_schema = DATABASE() AND actual.table_name = required.table_name
|
||||||
@@ -838,6 +856,69 @@ REMOTE
|
|||||||
[[ -z "$missing_work_assistant_columns" ]] \
|
[[ -z "$missing_work_assistant_columns" ]] \
|
||||||
|| fail "remote work-assistant schema missing required columns: $(printf '%s' "$missing_work_assistant_columns" | tr '\n' ', ' | sed 's/, $//')"
|
|| fail "remote work-assistant schema missing required columns: $(printf '%s' "$missing_work_assistant_columns" | tr '\n' ', ' | sed 's/, $//')"
|
||||||
|
|
||||||
|
local missing_knowledge_lifecycle_columns
|
||||||
|
missing_knowledge_lifecycle_columns="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
|
||||||
|
set -euo pipefail
|
||||||
|
db="$1"
|
||||||
|
mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL'
|
||||||
|
SELECT CONCAT(required.table_name, '.', required.column_name)
|
||||||
|
FROM (
|
||||||
|
SELECT 'aihr_data_asset' AS table_name, 'published_version_id' AS column_name
|
||||||
|
UNION ALL SELECT 'aihr_data_asset', 'candidate_version_id'
|
||||||
|
UNION ALL SELECT 'aihr_data_asset', 'published_time'
|
||||||
|
UNION ALL SELECT 'aihr_data_asset', 'retention_class'
|
||||||
|
UNION ALL SELECT 'aihr_data_asset', 'legal_hold'
|
||||||
|
UNION ALL SELECT 'aihr_data_asset', 'delete_state'
|
||||||
|
UNION ALL SELECT 'aihr_data_version', 'version_status'
|
||||||
|
UNION ALL SELECT 'aihr_data_version', 'revision_of_version_id'
|
||||||
|
UNION ALL SELECT 'aihr_data_version', 'revision_reason'
|
||||||
|
UNION ALL SELECT 'aihr_chunk_revision', 'index_generation'
|
||||||
|
UNION ALL SELECT 'aihr_chunk_revision', 'vector_point_id'
|
||||||
|
UNION ALL SELECT 'aihr_index_outbox', 'index_generation'
|
||||||
|
UNION ALL SELECT 'aihr_migration_run', 'verified_dry_run_id'
|
||||||
|
) required
|
||||||
|
LEFT JOIN information_schema.columns actual
|
||||||
|
ON actual.table_schema = DATABASE()
|
||||||
|
AND actual.table_name = required.table_name
|
||||||
|
AND actual.column_name = required.column_name
|
||||||
|
WHERE actual.column_name IS NULL
|
||||||
|
ORDER BY required.table_name, required.column_name;
|
||||||
|
SQL
|
||||||
|
REMOTE
|
||||||
|
)" || fail "remote knowledge-lifecycle schema check failed: $remote_ssh:$remote_db"
|
||||||
|
|
||||||
|
[[ -z "$missing_knowledge_lifecycle_columns" ]] \
|
||||||
|
|| fail "remote knowledge-lifecycle schema missing required columns: $(printf '%s' "$missing_knowledge_lifecycle_columns" | tr '\n' ', ' | sed 's/, $//')"
|
||||||
|
|
||||||
|
local knowledge_lifecycle_index_contract
|
||||||
|
knowledge_lifecycle_index_contract="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
|
||||||
|
set -euo pipefail
|
||||||
|
db="$1"
|
||||||
|
mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL'
|
||||||
|
SELECT CONCAT(
|
||||||
|
COALESCE((SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index)
|
||||||
|
FROM information_schema.statistics WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'aihr_data_asset' AND index_name = 'idx_aihr_data_asset_published_version'), 'missing'), '|',
|
||||||
|
COALESCE((SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index)
|
||||||
|
FROM information_schema.statistics WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'aihr_data_asset' AND index_name = 'idx_aihr_data_asset_candidate_version'), 'missing'), '|',
|
||||||
|
COALESCE((SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index)
|
||||||
|
FROM information_schema.statistics WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'aihr_data_version' AND index_name = 'idx_aihr_data_version_status'), 'missing'), '|',
|
||||||
|
COALESCE((SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index)
|
||||||
|
FROM information_schema.statistics WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'aihr_chunk_revision' AND index_name = 'idx_aihr_chunk_revision_generation'), 'missing'), '|',
|
||||||
|
COALESCE((SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index)
|
||||||
|
FROM information_schema.statistics WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'aihr_migration_run' AND index_name = 'idx_aihr_migration_verified_dry_run'), 'missing')
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
REMOTE
|
||||||
|
)" || fail "remote knowledge-lifecycle index check failed: $remote_ssh:$remote_db"
|
||||||
|
|
||||||
|
[[ "$knowledge_lifecycle_index_contract" = "tenant_id,published_version_id|tenant_id,candidate_version_id|tenant_id,version_status,id|tenant_id,asset_id,version_id,index_generation|tenant_id,verified_dry_run_id" ]] \
|
||||||
|
|| fail "remote knowledge-lifecycle index contract is invalid: got ${knowledge_lifecycle_index_contract:-missing}"
|
||||||
|
|
||||||
local media_reprocess_contract
|
local media_reprocess_contract
|
||||||
media_reprocess_contract="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
|
media_reprocess_contract="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -865,7 +946,7 @@ REMOTE
|
|||||||
|
|
||||||
[[ "$media_reprocess_contract" = "source_attach_id|tenant_id,source_attach_id,id" ]] \
|
[[ "$media_reprocess_contract" = "source_attach_id|tenant_id,source_attach_id,id" ]] \
|
||||||
|| fail "remote media-reprocess schema is invalid: got ${media_reprocess_contract:-missing}"
|
|| fail "remote media-reprocess schema is invalid: got ${media_reprocess_contract:-missing}"
|
||||||
echo "remote_schema=64/64 $remote_ssh:$remote_db"
|
echo "remote_schema=82/82 $remote_ssh:$remote_db"
|
||||||
echo "remote_media_reprocess_contract=1/1 $remote_ssh:$remote_db"
|
echo "remote_media_reprocess_contract=1/1 $remote_ssh:$remote_db"
|
||||||
echo "remote_work_report_idempotency=3/3 $remote_ssh:$remote_db"
|
echo "remote_work_report_idempotency=3/3 $remote_ssh:$remote_db"
|
||||||
echo "remote_learning_task_question_bank=6/6 $remote_ssh:$remote_db"
|
echo "remote_learning_task_question_bank=6/6 $remote_ssh:$remote_db"
|
||||||
@@ -884,6 +965,8 @@ REMOTE
|
|||||||
echo "remote_broadcast_attachment_contract=15/15 $remote_ssh:$remote_db"
|
echo "remote_broadcast_attachment_contract=15/15 $remote_ssh:$remote_db"
|
||||||
echo "remote_broadcast_question_context=1/1 $remote_ssh:$remote_db"
|
echo "remote_broadcast_question_context=1/1 $remote_ssh:$remote_db"
|
||||||
echo "remote_work_assistant_columns=8/8 $remote_ssh:$remote_db"
|
echo "remote_work_assistant_columns=8/8 $remote_ssh:$remote_db"
|
||||||
|
echo "remote_knowledge_lifecycle_columns=13/13 $remote_ssh:$remote_db"
|
||||||
|
echo "remote_knowledge_lifecycle_indexes=5/5 $remote_ssh:$remote_db"
|
||||||
echo "remote_personal_oss_configuration=true $remote_ssh:$remote_db"
|
echo "remote_personal_oss_configuration=true $remote_ssh:$remote_db"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry
|
|||||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260812_pipeline_run_sampling_mysql8.sql"
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260812_pipeline_run_sampling_mysql8.sql"
|
||||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260813_golden_calibration_mysql8.sql"
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260813_golden_calibration_mysql8.sql"
|
||||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260814_knowledge_privacy_derivative_mysql8.sql"
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260814_knowledge_privacy_derivative_mysql8.sql"
|
||||||
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260815_knowledge_rollout_compat_mysql8.sql"
|
||||||
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260816_knowledge_version_pointers_mysql8.sql"
|
||||||
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260817_knowledge_review_assistance_mysql8.sql"
|
||||||
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260818_knowledge_generation_revision_mysql8.sql"
|
||||||
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260819_knowledge_retention_deletion_mysql8.sql"
|
||||||
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260820_knowledge_migration_orchestration_mysql8.sql"
|
||||||
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260821_knowledge_shadow_gate_mysql8.sql"
|
||||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_personal_knowledge_mysql8.sql"
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_personal_knowledge_mysql8.sql"
|
||||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_model_mysql8.sql"
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_model_mysql8.sql"
|
||||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260725_deepseek_v4_model_mysql8.sql"
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260725_deepseek_v4_model_mysql8.sql"
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ grep -Fq 'artifact_commit="${RELEASE_ARTIFACT_COMMIT:-HEAD}"' "$SCRIPT"
|
|||||||
grep -Fq 'git merge-base --is-ancestor "$artifact_commit_sha" HEAD' "$SCRIPT"
|
grep -Fq 'git merge-base --is-ancestor "$artifact_commit_sha" HEAD' "$SCRIPT"
|
||||||
grep -Fq 'remote_media_reprocess_contract=1/1' "$SCRIPT"
|
grep -Fq 'remote_media_reprocess_contract=1/1' "$SCRIPT"
|
||||||
grep -Fq "source_attach_id|tenant_id,source_attach_id,id" "$SCRIPT"
|
grep -Fq "source_attach_id|tenant_id,source_attach_id,id" "$SCRIPT"
|
||||||
|
grep -Fq "UNION ALL SELECT 'aihr_knowledge_rollout_scope'" "$SCRIPT"
|
||||||
|
grep -Fq "UNION ALL SELECT 'aihr_generation_activation'" "$SCRIPT"
|
||||||
|
grep -Fq "UNION ALL SELECT 'aihr_data_asset', 'delete_state'" "$SCRIPT"
|
||||||
|
grep -Fq "UNION ALL SELECT 'aihr_migration_run', 'verified_dry_run_id'" "$SCRIPT"
|
||||||
|
grep -Fq 'remote_knowledge_lifecycle_columns=13/13' "$SCRIPT"
|
||||||
|
grep -Fq 'remote_knowledge_lifecycle_indexes=5/5' "$SCRIPT"
|
||||||
|
grep -Fq 'tenant_id,published_version_id|tenant_id,candidate_version_id|tenant_id,version_status,id|tenant_id,asset_id,version_id,index_generation|tenant_id,verified_dry_run_id' "$SCRIPT"
|
||||||
grep -Fq 'RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true ./scripts/release-preflight.sh' "$DEV_SETUP"
|
grep -Fq 'RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true ./scripts/release-preflight.sh' "$DEV_SETUP"
|
||||||
grep -Fq 'RELEASE_VERIFY_AUG1_READINESS=true ./scripts/release-preflight.sh' "$DEV_SETUP"
|
grep -Fq 'RELEASE_VERIFY_AUG1_READINESS=true ./scripts/release-preflight.sh' "$DEV_SETUP"
|
||||||
grep -Fq 'RELEASE_VERIFY_REMOTE_MATCH=true RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true RELEASE_VERIFY_AUG1_READINESS=true ./scripts/release-preflight.sh' "$DEV_SETUP"
|
grep -Fq 'RELEASE_VERIFY_REMOTE_MATCH=true RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true RELEASE_VERIFY_AUG1_READINESS=true ./scripts/release-preflight.sh' "$DEV_SETUP"
|
||||||
|
|||||||
Reference in New Issue
Block a user