From df77998e0cdf86ccf1b7fb3973218fd44eaca278 Mon Sep 17 00:00:00 2001 From: key Date: Mon, 3 Aug 2026 14:34:54 +0800 Subject: [PATCH] feat: complete governed knowledge lifecycle --- .../AihrKnowledgeIndexOutboxService.java | 91 ++- .../AihrKnowledgeLegacyMigrationService.java | 60 +- .../AihrKnowledgeLifecycleService.java | 340 ++++++++-- ...nowledgeMigrationOrchestrationService.java | 298 +++++++++ .../AihrKnowledgeQualityController.java | 164 +++++ ...AihrKnowledgeQualityMonitoringService.java | 17 +- .../AihrKnowledgeRetentionService.java | 450 +++++++++++++ .../AihrKnowledgeReviewAssistanceService.java | 334 ++++++++++ .../quality/AihrKnowledgeRolloutService.java | 228 +++++++ .../AihrKnowledgeSemanticAnalysisService.java | 6 +- .../AihrKnowledgeShadowRolloutService.java | 434 ++++++++++++ ...AihrKnowledgeVersionGovernanceService.java | 305 +++++++++ .../AihrKnowledgeCitationDetailService.java | 47 +- .../AihrKnowledgeQueryAuditService.java | 3 +- .../service/AihrKnowledgeQueryService.java | 26 +- .../aihr/learning/AihrExamService.java | 6 +- .../aihr/service/AihrSopSeedService.java | 303 +++++++-- ...ihrKnowledgeCitationDetailServiceTest.java | 7 +- .../AihrKnowledgeQueryServiceTest.java | 9 +- ...rKnowledgeResourceDownloadServiceTest.java | 5 +- .../AihrDataQualityLifecycleSchemaTest.java | 88 +++ .../AihrKnowledgeGovernanceSafetyTest.java | 73 +++ ...hrKnowledgeLegacyMigrationServiceTest.java | 14 + ...rKnowledgeReviewAssistanceServiceTest.java | 53 ++ .../AihrKnowledgeRolloutServiceTest.java | 35 + .../aihr/service/AihrSopSeedServiceTest.java | 5 +- ...260815_knowledge_rollout_compat_mysql8.sql | 66 ++ ...0816_knowledge_version_pointers_mysql8.sql | 136 ++++ ...817_knowledge_review_assistance_mysql8.sql | 84 +++ ...8_knowledge_generation_revision_mysql8.sql | 153 +++++ ...19_knowledge_retention_deletion_mysql8.sql | 110 ++++ ...owledge_migration_orchestration_mysql8.sql | 108 +++ ..._20260821_knowledge_shadow_gate_mysql8.sql | 80 +++ docs/API_INTEGRATION.md | 35 + docs/BRD_IMPLEMENTATION_AUDIT.md | 18 +- docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md | 11 + frontend/src/api/aihr/processing.ts | 228 +++++++ frontend/src/views/knowledge/processing.vue | 617 +++++++++++++++++- scripts/release-preflight.sh | 85 ++- scripts/reset-dev-db.sh | 7 + scripts/tests/release-preflight-scope.test.sh | 7 + 41 files changed, 4921 insertions(+), 225 deletions(-) create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeMigrationOrchestrationService.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeRetentionService.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeReviewAssistanceService.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeRolloutService.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeShadowRolloutService.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeVersionGovernanceService.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeGovernanceSafetyTest.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeReviewAssistanceServiceTest.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeRolloutServiceTest.java create mode 100644 backend/script/sql/update/aihr_20260815_knowledge_rollout_compat_mysql8.sql create mode 100644 backend/script/sql/update/aihr_20260816_knowledge_version_pointers_mysql8.sql create mode 100644 backend/script/sql/update/aihr_20260817_knowledge_review_assistance_mysql8.sql create mode 100644 backend/script/sql/update/aihr_20260818_knowledge_generation_revision_mysql8.sql create mode 100644 backend/script/sql/update/aihr_20260819_knowledge_retention_deletion_mysql8.sql create mode 100644 backend/script/sql/update/aihr_20260820_knowledge_migration_orchestration_mysql8.sql create mode 100644 backend/script/sql/update/aihr_20260821_knowledge_shadow_gate_mysql8.sql diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeIndexOutboxService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeIndexOutboxService.java index 88fc226c..b8382636 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeIndexOutboxService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeIndexOutboxService.java @@ -20,8 +20,8 @@ public class AihrKnowledgeIndexOutboxService { private final JdbcTemplate jdbcTemplate; private final AihrSopSeedService sopSeedService; - @Value("${aihr.qdrant.collection:${AIHR_QDRANT_COLLECTION:aihr_knowledge}}") - private String productionCollection = "aihr_knowledge"; + @Value("${aihr.qdrant.governed-collection:${AIHR_QDRANT_GOVERNED_COLLECTION:aihr_knowledge_governed_v1}}") + private String productionCollection = AihrKnowledgeRolloutService.DEFAULT_GOVERNED_COLLECTION; @Value("${aihr.knowledge.index-outbox-max-attempts:8}") private int maxAttempts = 8; @@ -36,34 +36,47 @@ public class AihrKnowledgeIndexOutboxService { """); jdbcTemplate.update(""" insert into aihr_index_outbox - (tenant_id, asset_id, version_id, operation, target_collection, payload_json, status, + (tenant_id, asset_id, version_id, operation, target_collection, index_generation, + payload_json, status, attempt_count, next_attempt_time, create_time, update_time) - select a.tenant_id, a.id, a.current_version_id, + select a.tenant_id, a.id, coalesce(a.published_version_id, a.current_version_id), case when a.lifecycle_status = 'PUBLISHED' then 'UPSERT' else 'DELETE' end, - ?, json_object('docId', a.doc_id), 'PENDING', 0, now(), now(), now() + coalesce(scope.governed_collection, ?), coalesce(scope.active_generation, 1), + json_object('docId', a.doc_id, 'assetId', a.id, + 'versionId', coalesce(a.published_version_id, a.current_version_id), + 'generation', coalesce(scope.active_generation, 1)), + 'PENDING', 0, now(), now(), now() from aihr_data_asset a + left join aihr_knowledge_rollout_scope scope + on scope.tenant_id = a.tenant_id and scope.knowledge_id = a.knowledge_id where a.lifecycle_status in ('PUBLISHED', 'DEPRECATED') and a.index_status in ('PENDING', 'FAILED') and not exists ( select 1 from aihr_index_outbox active_job where active_job.tenant_id = a.tenant_id and active_job.asset_id = a.id - and active_job.version_id = a.current_version_id + and active_job.version_id = coalesce(a.published_version_id, a.current_version_id) and active_job.status in ('PENDING', 'PROCESSING', 'FAILED', 'DEAD_LETTER') ) """, productionCollection); List rows = jdbcTemplate.query(""" select o.id, o.tenant_id, o.asset_id, o.version_id, o.operation, + o.target_collection, o.index_generation, o.attempt_count, a.knowledge_id, a.doc_id, a.lifecycle_status, - a.current_version_id + coalesce(a.published_version_id, a.current_version_id), + coalesce(scope.active_generation, 1) active_generation from aihr_index_outbox o join aihr_data_asset a on a.tenant_id = o.tenant_id and a.id = o.asset_id + left join aihr_knowledge_rollout_scope scope + on scope.tenant_id = a.tenant_id and scope.knowledge_id = a.knowledge_id where o.status in ('PENDING', 'FAILED') and o.next_attempt_time <= now() order by o.id limit 20 """, (rs, rowNum) -> new OutboxRow(rs.getLong("id"), rs.getString("tenant_id"), rs.getLong("asset_id"), rs.getLong("version_id"), rs.getString("operation"), - rs.getInt("attempt_count"), rs.getLong("knowledge_id"), rs.getString("doc_id"), - rs.getString("lifecycle_status"), rs.getLong("current_version_id"))); + rs.getString("target_collection"), rs.getLong("index_generation"), rs.getInt("attempt_count"), + rs.getLong("knowledge_id"), rs.getString("doc_id"), + rs.getString("lifecycle_status"), rs.getLong("current_version_id"), + rs.getLong("active_generation"))); rows.forEach(this::claimAndProcess); } catch (RuntimeException ex) { // The migration is additive. A pre-migration process must continue serving non-knowledge APIs. @@ -91,10 +104,12 @@ public class AihrKnowledgeIndexOutboxService { try { Integer vectors = TenantHelper.dynamic(row.tenantId(), () -> { if ("DELETE".equals(row.operation())) { - sopSeedService.deleteVectorDocument(row.knowledgeId(), row.docId()); + sopSeedService.deleteVectorDocument(row.knowledgeId(), row.docId(), + row.collection(), row.generation()); return null; } - return sopSeedService.vectorizePublishedDocument(row.knowledgeId(), row.docId()); + return sopSeedService.vectorizePublishedDocument(row.knowledgeId(), row.docId(), + row.versionId(), row.collection(), row.generation()); }); jdbcTemplate.update(""" update aihr_index_outbox @@ -104,9 +119,53 @@ public class AihrKnowledgeIndexOutboxService { jdbcTemplate.update(""" update aihr_data_asset set index_status = ?, update_time = now() - where tenant_id = ? and id = ? and current_version_id = ? + where tenant_id = ? and id = ? + and coalesce(published_version_id, current_version_id) = ? """, "DELETE".equals(row.operation()) || vectors == null || vectors == 0 ? "NOT_REQUIRED" : "READY", row.tenantId(), row.assetId(), row.versionId()); + if ("UPSERT".equals(row.operation()) && vectors != null && vectors > 0) { + jdbcTemplate.update(""" + insert into aihr_knowledge_generation + (tenant_id, knowledge_id, generation, collection_name, status, + version_count, point_count, create_time, ready_time, activated_time) + values (?, ?, ?, ?, ?, 0, 0, now(), ?, ?) + on duplicate key update collection_name = values(collection_name), + status = case when status = 'FAILED' then 'BUILDING' else status end, + ready_time = coalesce(ready_time, values(ready_time)), + activated_time = coalesce(activated_time, values(activated_time)), last_error = null + """, row.tenantId(), row.knowledgeId(), row.generation(), row.collection(), + row.generation() == row.activeGeneration() ? "ACTIVE" : "BUILDING", + row.generation() == row.activeGeneration() ? java.time.LocalDateTime.now() : null, + row.generation() == row.activeGeneration() ? java.time.LocalDateTime.now() : null); + jdbcTemplate.update(""" + insert into aihr_generation_version + (tenant_id, knowledge_id, generation, asset_id, version_id, content_sha256, + point_count, status, create_time) + select a.tenant_id, a.knowledge_id, ?, a.id, ?, v.content_sha256, ?, 'INCLUDED', now() + from aihr_data_asset a + join aihr_data_version v on v.tenant_id = a.tenant_id and v.id = ? + where a.tenant_id = ? and a.id = ? + on duplicate key update version_id = values(version_id), + content_sha256 = values(content_sha256), point_count = values(point_count), status = 'INCLUDED' + """, row.generation(), row.versionId(), vectors, row.versionId(), + row.tenantId(), row.assetId()); + jdbcTemplate.update(""" + update aihr_knowledge_generation generation_row + set version_count = (select count(*) from aihr_generation_version member + where member.tenant_id = generation_row.tenant_id + and member.knowledge_id = generation_row.knowledge_id + and member.generation = generation_row.generation + and member.status = 'INCLUDED'), + point_count = (select coalesce(sum(member.point_count), 0) + from aihr_generation_version member + where member.tenant_id = generation_row.tenant_id + and member.knowledge_id = generation_row.knowledge_id + and member.generation = generation_row.generation + and member.status = 'INCLUDED') + where generation_row.tenant_id = ? and generation_row.knowledge_id = ? + and generation_row.generation = ? + """, row.tenantId(), row.knowledgeId(), row.generation()); + } } catch (RuntimeException ex) { String message = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage(); int attempted = row.attemptCount() + 1; @@ -128,7 +187,8 @@ public class AihrKnowledgeIndexOutboxService { } jdbcTemplate.update(""" update aihr_data_asset set index_status = 'FAILED', update_time = now() - where tenant_id = ? and id = ? and current_version_id = ? + where tenant_id = ? and id = ? + and coalesce(published_version_id, current_version_id) = ? """, row.tenantId(), row.assetId(), row.versionId()); if ("DEAD_LETTER".equals(failureStatus)) { log.error("knowledge index outbox dead-lettered id={} operation={} attempts={} reason={}", @@ -191,7 +251,8 @@ public class AihrKnowledgeIndexOutboxService { } private record OutboxRow(long id, String tenantId, long assetId, long versionId, String operation, - int attemptCount, long knowledgeId, String docId, String lifecycleStatus, - long currentVersionId) { + String collection, long generation, int attemptCount, long knowledgeId, + String docId, String lifecycleStatus, + long currentVersionId, long activeGeneration) { } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeLegacyMigrationService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeLegacyMigrationService.java index 751af131..044d752b 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeLegacyMigrationService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeLegacyMigrationService.java @@ -21,6 +21,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Locale; +import java.util.Set; @Service @RequiredArgsConstructor @@ -39,19 +41,39 @@ public class AihrKnowledgeLegacyMigrationService { } public MigrationPreview previewBatch(String tenantId, long afterAttachmentId, int limit) { + return previewBatch(tenantId, null, afterAttachmentId, limit); + } + + public MigrationPreview previewBatch(String tenantId, Long knowledgeId, long afterAttachmentId, int limit) { + return previewBatch(tenantId, knowledgeId, afterAttachmentId, limit, "ALL"); + } + + public MigrationPreview previewBatch(String tenantId, Long knowledgeId, long afterAttachmentId, int limit, + String riskScope) { int boundedLimit = Math.max(1, Math.min(limit, 100)); long cursor = Math.max(0L, afterAttachmentId); - List rows = findCandidates(tenantId, cursor, boundedLimit); + String scope = normalizeRiskScope(riskScope); + List rows = findCandidates(tenantId, knowledgeId, cursor, boundedLimit, scope); long nextCursor = rows.isEmpty() ? cursor : rows.get(rows.size() - 1).attachmentId(); int sourceUnavailable = (int) rows.stream().filter(row -> row.ossId() == null).count(); return new MigrationPreview(rows.size(), rows.size() - sourceUnavailable, sourceUnavailable, - nextCursor, hasMore(tenantId, nextCursor)); + nextCursor, hasMore(tenantId, knowledgeId, nextCursor, scope)); } public MigrationResult stageBatch(String tenantId, long afterAttachmentId, int limit) { + return stageBatch(tenantId, null, afterAttachmentId, limit); + } + + public MigrationResult stageBatch(String tenantId, Long knowledgeId, long afterAttachmentId, int limit) { + return stageBatch(tenantId, knowledgeId, afterAttachmentId, limit, "ALL"); + } + + public MigrationResult stageBatch(String tenantId, Long knowledgeId, long afterAttachmentId, int limit, + String riskScope) { int boundedLimit = Math.max(1, Math.min(limit, 100)); long cursor = Math.max(0L, afterAttachmentId); - List rows = findCandidates(tenantId, cursor, boundedLimit); + String scope = normalizeRiskScope(riskScope); + List rows = findCandidates(tenantId, knowledgeId, cursor, boundedLimit, scope); List stagedAssetIds = new ArrayList<>(); List failedAttachmentIds = new ArrayList<>(); @@ -90,20 +112,23 @@ public class AihrKnowledgeLegacyMigrationService { } } } - boolean moreAvailable = hasMore(tenantId, nextCursor); + boolean moreAvailable = hasMore(tenantId, knowledgeId, nextCursor, scope); return new MigrationResult(rows.size(), stagedAssetIds.size(), reparsed, quarantined, List.copyOf(stagedAssetIds), List.copyOf(failedAttachmentIds), nextCursor, moreAvailable); } - private List findCandidates(String tenantId, long cursor, int limit) { + private List findCandidates(String tenantId, Long knowledgeId, long cursor, int limit, + String riskScope) { return jdbcTemplate.query(""" select a.id, a.knowledge_id, a.doc_id, case when o.oss_id is null then null else a.oss_id end verified_oss_id, coalesce(a.name, a.doc_id) source_name, a.create_by from aihr_knowledge_attach a left join sys_oss o on o.oss_id = a.oss_id and binary o.tenant_id = binary a.tenant_id - where a.tenant_id = ? and a.id > ? and a.status = 2 + where a.tenant_id = ? and (? is null or a.knowledge_id = ?) and a.id > ? and a.status = 2 and nullif(trim(a.doc_id), '') is not null + and (? = 'ALL' or (? = 'LOW_RISK' and o.oss_id is not null) + or (? = 'QUARANTINE_FIRST' and o.oss_id is null)) and not exists ( select 1 from aihr_data_asset governed where governed.tenant_id = a.tenant_id and governed.knowledge_id = a.knowledge_id @@ -113,7 +138,8 @@ public class AihrKnowledgeLegacyMigrationService { limit ? """, (rs, rowNum) -> new LegacyAttachment(rs.getLong("id"), rs.getLong("knowledge_id"), rs.getString("doc_id"), rs.getObject("verified_oss_id", Long.class), rs.getString("source_name"), - rs.getObject("create_by", Long.class)), tenantId, cursor, limit); + rs.getObject("create_by", Long.class)), tenantId, knowledgeId, knowledgeId, cursor, + riskScope, riskScope, riskScope, limit); } private UploadResponse restageFromRaw(String tenantId, LegacyAttachment row) throws IOException { @@ -130,7 +156,7 @@ public class AihrKnowledgeLegacyMigrationService { copyBounded(input, output, MAX_RAW_BYTES); } return TenantHelper.dynamic(tenantId, - () -> sopSeedService.reprocessStagedAttachment(row.attachmentId(), staged)); + () -> sopSeedService.stageLegacyAttachmentCandidate(row.attachmentId(), staged)); } finally { try { Files.deleteIfExists(staged); @@ -158,20 +184,32 @@ public class AihrKnowledgeLegacyMigrationService { return assetIds.isEmpty() ? null : assetIds.get(0); } - private boolean hasMore(String tenantId, long cursor) { + private boolean hasMore(String tenantId, Long knowledgeId, long cursor, String riskScope) { Integer count = jdbcTemplate.queryForObject(""" select count(*) from aihr_knowledge_attach a - where a.tenant_id = ? and a.id > ? and a.status = 2 + left join sys_oss o on o.oss_id = a.oss_id and binary o.tenant_id = binary a.tenant_id + where a.tenant_id = ? and (? is null or a.knowledge_id = ?) and a.id > ? and a.status = 2 and nullif(trim(a.doc_id), '') is not null + and (? = 'ALL' or (? = 'LOW_RISK' and o.oss_id is not null) + or (? = 'QUARANTINE_FIRST' and o.oss_id is null)) and not exists ( select 1 from aihr_data_asset governed where governed.tenant_id = a.tenant_id and governed.knowledge_id = a.knowledge_id and governed.doc_id = a.doc_id ) - """, Integer.class, tenantId, cursor); + """, Integer.class, tenantId, knowledgeId, knowledgeId, cursor, + riskScope, riskScope, riskScope); return count != null && count > 0; } + private static String normalizeRiskScope(String value) { + String normalized = value == null ? "ALL" : value.trim().toUpperCase(Locale.ROOT); + if (!Set.of("ALL", "LOW_RISK", "QUARANTINE_FIRST").contains(normalized)) { + throw new IllegalArgumentException("Unknown migration risk scope"); + } + return normalized; + } + private static void copyBounded(InputStream input, OutputStream output, long maxBytes) throws IOException { byte[] buffer = new byte[8192]; long total = 0; diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeLifecycleService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeLifecycleService.java index 6d02cf55..4cbe69ac 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeLifecycleService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeLifecycleService.java @@ -42,8 +42,8 @@ public class AihrKnowledgeLifecycleService { private final AihrKnowledgeQualityGateService qualityGateService; private final AihrKnowledgeClaimService claimService; - @Value("${aihr.qdrant.collection:${AIHR_QDRANT_COLLECTION:aihr_knowledge}}") - private String productionCollection = "aihr_knowledge"; + @Value("${aihr.qdrant.governed-collection:${AIHR_QDRANT_GOVERNED_COLLECTION:aihr_knowledge_governed_v1}}") + private String productionCollection = AihrKnowledgeRolloutService.DEFAULT_GOVERNED_COLLECTION; @Transactional public StagedAsset stageParsedDocument(StageCommand command) { @@ -154,12 +154,22 @@ public class AihrKnowledgeLifecycleService { semantic_detector_version = ? where tenant_id = ? and id = ? """, AihrKnowledgeSemanticAnalysisService.DETECTOR_VERSION, command.tenantId(), versionId); } + jdbcTemplate.update(""" + update aihr_data_version + set version_status = ? + where tenant_id = ? and id = ? and immutable_status = 'SEALED' + """, assessment.status().name(), command.tenantId(), versionId); jdbcTemplate.update(""" update aihr_data_asset - set current_version_id = ?, lifecycle_status = ?, index_status = 'NOT_REQUIRED', - trust_level = 'UNTRUSTED', content_sha256 = ?, update_time = now() + set candidate_version_id = ?, + current_version_id = case when published_version_id is null then ? else current_version_id end, + lifecycle_status = case when published_version_id is null then ? else lifecycle_status end, + index_status = case when published_version_id is null then 'NOT_REQUIRED' else index_status end, + trust_level = case when published_version_id is null then 'UNTRUSTED' else trust_level end, + content_sha256 = case when published_version_id is null then ? else content_sha256 end, + update_time = now() where tenant_id = ? and id = ? - """, versionId, assessment.status().name(), contentHash, command.tenantId(), assetId); + """, versionId, versionId, assessment.status().name(), contentHash, command.tenantId(), assetId); List reasonCodes = new ArrayList<>(assessment.findings().stream() .map(finding -> finding.reasonCode().name()).toList()); dynamicReasons.stream().map(Enum::name).forEach(reasonCodes::add); @@ -191,12 +201,22 @@ public class AihrKnowledgeLifecycleService { String.valueOf(versionId), "PARSE_FAILED", command.extractionQuality().extractorName(), command.extractionQuality().extractorVersion()); } + jdbcTemplate.update(""" + update aihr_data_version + set version_status = 'QUARANTINED' + where tenant_id = ? and id = ? and immutable_status = 'SEALED' + """, command.tenantId(), versionId); jdbcTemplate.update(""" update aihr_data_asset - set current_version_id = ?, lifecycle_status = 'QUARANTINED', index_status = 'NOT_REQUIRED', - trust_level = 'UNTRUSTED', content_sha256 = ?, update_time = now() + set candidate_version_id = ?, + current_version_id = case when published_version_id is null then ? else current_version_id end, + lifecycle_status = case when published_version_id is null then 'QUARANTINED' else lifecycle_status end, + index_status = case when published_version_id is null then 'NOT_REQUIRED' else index_status end, + trust_level = case when published_version_id is null then 'UNTRUSTED' else trust_level end, + content_sha256 = case when published_version_id is null then ? else content_sha256 end, + update_time = now() where tenant_id = ? and id = ? - """, versionId, rawHash, command.tenantId(), assetId); + """, versionId, versionId, rawHash, command.tenantId(), assetId); return new StagedAsset(assetId, versionId, versionNo, Status.QUARANTINED.name(), List.of(command.reasonCode().name()), 0); } @@ -212,7 +232,8 @@ public class AihrKnowledgeLifecycleService { a.source_authority, a.source_version, a.data_class, a.lifecycle_status, a.index_status, a.applicable_region, a.applicable_project, a.applicable_role, a.effective_from, a.effective_to, - a.current_version_id, v.semantic_analysis_status, v.semantic_last_error, + coalesce(a.candidate_version_id, a.current_version_id) as current_version_id, + v.semantic_analysis_status, v.semantic_last_error, v.version_status, v.semantic_retry_count, a.update_time, (select count(*) from aihr_quality_issue q where q.tenant_id = a.tenant_id and q.asset_id = a.id and q.status = 'OPEN') issue_count, @@ -220,8 +241,11 @@ public class AihrKnowledgeLifecycleService { where q.tenant_id = a.tenant_id and q.asset_id = a.id and q.status = 'OPEN' and q.gate_type = 'HARD') blocking_issue_count from aihr_data_asset a - join aihr_data_version v on v.tenant_id = a.tenant_id and v.id = a.current_version_id - where a.tenant_id = ? and a.lifecycle_status = ? + join aihr_data_version v on v.tenant_id = a.tenant_id + and v.id = coalesce(a.candidate_version_id, a.current_version_id) + where a.tenant_id = ? + and ((? = 'PUBLISHED' and a.lifecycle_status = 'PUBLISHED') + or (? <> 'PUBLISHED' and v.version_status = ?)) order by a.update_time asc, a.id asc limit ? """, (rs, rowNum) -> new AssetSummary( @@ -235,7 +259,7 @@ public class AihrKnowledgeLifecycleService { rs.getObject("current_version_id", Long.class), rs.getString("semantic_analysis_status"), rs.getString("semantic_last_error"), rs.getInt("semantic_retry_count"), rs.getInt("issue_count"), rs.getInt("blocking_issue_count"), rs.getObject("update_time", LocalDateTime.class)), - tenantId, normalizedStatus, boundedLimit); + tenantId, normalizedStatus, normalizedStatus, normalizedStatus, boundedLimit); } public List issues(String tenantId, long assetId) { @@ -256,7 +280,8 @@ public class AihrKnowledgeLifecycleService { select v.id, v.privacy_status, v.redaction_policy_version, v.redaction_summary_json, v.redacted_source_name, v.redacted_content from aihr_data_asset a - join aihr_data_version v on v.tenant_id = a.tenant_id and v.id = a.current_version_id + join aihr_data_version v on v.tenant_id = a.tenant_id + and v.id = coalesce(a.candidate_version_id, a.current_version_id) where a.tenant_id = ? and a.id = ? limit 1 """, (rs, rowNum) -> new PrivacyPreview( @@ -276,15 +301,22 @@ public class AihrKnowledgeLifecycleService { } AssetRow asset = lockAsset(tenantId, assetId); Status current = Status.valueOf(asset.lifecycleStatus()); - if (!AihrKnowledgeLifecycle.canApprove(current)) { + boolean candidateReview = asset.candidateVersionId() != null + && versionStatus(tenantId, asset.versionId()).map(status -> + Set.of("REVIEW_PENDING", "QUARANTINED", "APPROVED").contains(status)).orElse(false); + if (!AihrKnowledgeLifecycle.canApprove(current) && !candidateReview) { throw new ServiceException("Asset is not awaiting review", HttpStatus.CONFLICT); } + long versionId = asset.versionId(); + if (command.expectedVersionId() != null && command.expectedVersionId() != versionId) { + throw new ServiceException("Candidate version changed; reload the review package", HttpStatus.CONFLICT); + } VersionGate versionGate = jdbcTemplate.queryForObject(""" select semantic_analysis_status, privacy_status, case when redacted_content is null or redaction_policy_version is null then 0 else 1 end privacy_ready from aihr_data_version where tenant_id = ? and id = ? """, (rs, rowNum) -> new VersionGate(rs.getString("semantic_analysis_status"), - rs.getString("privacy_status"), rs.getBoolean("privacy_ready")), tenantId, asset.versionId()); + rs.getString("privacy_status"), rs.getBoolean("privacy_ready")), tenantId, versionId); if (versionGate == null || !versionGate.privacyReady() || !Set.of("CLEAN", "REDACTED").contains(versionGate.privacyStatus())) { throw new ServiceException("A current privacy derivative and residual scan are required before approval", @@ -294,19 +326,19 @@ public class AihrKnowledgeLifecycleService { throw new ServiceException("Semantic duplicate analysis must complete before approval", HttpStatus.CONFLICT); } - claimService.requireResolvedConflicts(tenantId, assetId, asset.versionId()); - acceptSoftFindings(tenantId, assetId, asset.versionId(), reviewerId, command.acceptedReasonCodes()); + claimService.requireResolvedConflicts(tenantId, assetId, versionId); + acceptSoftFindings(tenantId, assetId, versionId, reviewerId, command.acceptedReasonCodes()); Integer hardOpen = jdbcTemplate.queryForObject(""" select count(*) from aihr_quality_issue where tenant_id = ? and asset_id = ? and version_id = ? and status = 'OPEN' and gate_type = 'HARD' - """, Integer.class, tenantId, assetId, asset.versionId()); + """, Integer.class, tenantId, assetId, versionId); if (hardOpen != null && hardOpen > 0) { throw new ServiceException("Blocking quality findings must be resolved before approval", HttpStatus.CONFLICT); } Integer softOpen = jdbcTemplate.queryForObject(""" select count(*) from aihr_quality_issue where tenant_id = ? and asset_id = ? and version_id = ? and status = 'OPEN' and gate_type = 'SOFT' - """, Integer.class, tenantId, assetId, asset.versionId()); + """, Integer.class, tenantId, assetId, versionId); if (softOpen != null && softOpen > 0) { throw new ServiceException("Every soft quality finding must be acknowledged before approval", HttpStatus.CONFLICT); } @@ -325,7 +357,7 @@ public class AihrKnowledgeLifecycleService { "Normative knowledge requires an approved authority type and source version", HttpStatus.BAD_REQUEST); } if (usage == UsageType.NORMATIVE_KNOWLEDGE - && hasQualityReason(tenantId, assetId, asset.versionId(), ReasonCode.VERSION_CONFLICT) + && hasQualityReason(tenantId, assetId, versionId, ReasonCode.VERSION_CONFLICT) && command.supersedesAssetId() == null) { throw new ServiceException("A conflicting normative source must explicitly supersede the old asset", HttpStatus.CONFLICT); @@ -344,13 +376,12 @@ public class AihrKnowledgeLifecycleService { "Superseded by asset " + assetId + ": " + command.reason().trim()); } - removePublishedFragments(tenantId, assetId); List chunks = jdbcTemplate.query(""" select id, chunk_index, content, locator_json from aihr_chunk_revision where tenant_id = ? and asset_id = ? and version_id = ? order by chunk_index """, (rs, rowNum) -> new ChunkRow(rs.getLong("id"), rs.getInt("chunk_index"), rs.getString("content"), rs.getString("locator_json")), - tenantId, assetId, asset.versionId()); + tenantId, assetId, versionId); if (chunks.isEmpty()) { throw new ServiceException("Approved version has no chunks", HttpStatus.CONFLICT); } @@ -374,7 +405,7 @@ public class AihrKnowledgeLifecycleService { """, Statement.RETURN_GENERATED_KEYS); statement.setString(1, tenantId); statement.setLong(2, assetId); - statement.setLong(3, asset.versionId()); + statement.setLong(3, versionId); statement.setString(4, command.reason().trim()); statement.setString(5, json(new Snapshot(asset.lifecycleStatus(), asset.dataClass(), asset.sourceVersion()))); statement.setString(6, json(new Snapshot("PUBLISHED", usage.name(), command.sourceVersion()))); @@ -388,15 +419,31 @@ public class AihrKnowledgeLifecycleService { values (?, ?, 'production', ?, 'ACTIVE', ?, now()) on duplicate key update usage_type = values(usage_type), status = 'ACTIVE', approved_review_id = values(approved_review_id) - """, tenantId, asset.versionId(), usage.name(), reviewId); - enqueueIndex(tenantId, assetId, asset.versionId(), "UPSERT", asset.docId()); + """, tenantId, versionId, usage.name(), reviewId); + if (asset.publishedVersionId() != null && asset.publishedVersionId() != versionId) { + jdbcTemplate.update(""" + update aihr_dataset_membership set status = 'DISABLED' + where tenant_id = ? and version_id = ? and dataset_code = 'production' + """, tenantId, asset.publishedVersionId()); + jdbcTemplate.update(""" + update aihr_data_version set version_status = 'SUPERSEDED' + where tenant_id = ? and id = ? and version_status = 'PUBLISHED' + """, tenantId, asset.publishedVersionId()); + } + jdbcTemplate.update(""" + update aihr_data_version set version_status = 'PUBLISHED' + where tenant_id = ? and id = ? and immutable_status = 'SEALED' + """, tenantId, versionId); + enqueueIndex(tenantId, assetId, versionId, "UPSERT", asset.docId()); jdbcTemplate.update(""" update aihr_data_asset - set lifecycle_status = 'PUBLISHED', index_status = 'PENDING', data_class = ?, update_time = now() + set lifecycle_status = 'PUBLISHED', index_status = 'PENDING', data_class = ?, + published_version_id = ?, candidate_version_id = null, current_version_id = ?, + published_time = coalesce(published_time, now()), update_time = now() where tenant_id = ? and id = ? - """, usage.name(), tenantId, assetId); - claimService.markPublished(tenantId, asset.versionId()); - return new PublishedAsset(assetId, asset.versionId(), "PUBLISHED", "PENDING", chunks.size(), reviewId); + """, usage.name(), versionId, versionId, tenantId, assetId); + claimService.markPublished(tenantId, versionId); + return new PublishedAsset(assetId, versionId, "PUBLISHED", "PENDING", chunks.size(), reviewId); } @Transactional @@ -408,13 +455,28 @@ public class AihrKnowledgeLifecycleService { if (!AihrKnowledgeLifecycle.canWithdraw(Status.valueOf(asset.lifecycleStatus()))) { throw new ServiceException("Only approved or published assets can be withdrawn", HttpStatus.CONFLICT); } - int fragments = removePublishedFragments(tenantId, assetId); + long versionId = asset.publishedVersionId() == null ? asset.versionId() : asset.publishedVersionId(); + Integer fragments = jdbcTemplate.queryForObject(""" + select count(*) from aihr_chunk_revision + where tenant_id = ? and asset_id = ? and version_id = ? and published_fragment_id is not null + """, Integer.class, tenantId, assetId, versionId); jdbcTemplate.update("update aihr_dataset_membership set status = 'DISABLED' where tenant_id = ? and version_id = ?", - tenantId, asset.versionId()); - enqueueIndex(tenantId, assetId, asset.versionId(), "DELETE", asset.docId()); + tenantId, versionId); + jdbcTemplate.update(""" + update aihr_data_version set version_status = 'WITHDRAWN' + where tenant_id = ? and id = ? and version_status in ('PUBLISHED','APPROVED','REVIEW_PENDING','QUARANTINED') + """, tenantId, versionId); + if (asset.candidateVersionId() != null && asset.candidateVersionId() != versionId) { + jdbcTemplate.update(""" + update aihr_data_version set version_status = 'WITHDRAWN' + where tenant_id = ? and id = ? and version_status in ('DRAFT','PROCESSING','REVIEW_PENDING','QUARANTINED','APPROVED') + """, tenantId, asset.candidateVersionId()); + } + enqueueIndex(tenantId, assetId, versionId, "DELETE", asset.docId()); jdbcTemplate.update(""" update aihr_data_asset - set lifecycle_status = 'DEPRECATED', index_status = 'PENDING', trust_level = 'WITHDRAWN', update_time = now() + set lifecycle_status = 'DEPRECATED', index_status = 'PENDING', trust_level = 'WITHDRAWN', + published_version_id = null, candidate_version_id = null, update_time = now() where tenant_id = ? and id = ? """, tenantId, assetId); claimService.markDeprecated(tenantId, assetId); @@ -422,8 +484,8 @@ public class AihrKnowledgeLifecycleService { insert into aihr_review_decision (tenant_id, asset_id, version_id, decision, reason, reviewer_id, reviewer_type, reviewed_time) values (?, ?, ?, 'WITHDRAW', ?, ?, 'HUMAN', now()) - """, tenantId, assetId, asset.versionId(), reason.trim(), reviewerId); - return new PublishedAsset(assetId, asset.versionId(), "DEPRECATED", "PENDING", fragments, null); + """, tenantId, assetId, versionId, reason.trim(), reviewerId); + return new PublishedAsset(assetId, versionId, "DEPRECATED", "PENDING", fragments == null ? 0 : fragments, null); } private long upsertAsset(StageCommand command, String contentHash) { @@ -650,7 +712,9 @@ public class AihrKnowledgeLifecycleService { List rows = jdbcTemplate.query(""" select a.id asset_id, v.id version_id from aihr_data_asset a - join aihr_data_version v on v.tenant_id = a.tenant_id and v.id = a.current_version_id + join aihr_data_version v on v.tenant_id = a.tenant_id + and v.id = coalesce(a.candidate_version_id, + a.published_version_id, a.current_version_id) where a.tenant_id = ? and a.id <> ? and v.content_sha256 = ? order by case a.lifecycle_status when 'PUBLISHED' then 0 else 1 end, a.update_time desc limit 1 @@ -711,7 +775,9 @@ public class AihrKnowledgeLifecycleService { return jdbcTemplate.query(""" select a.id, v.id version_id, v.simhash64 from aihr_data_asset a - join aihr_data_version v on v.tenant_id = a.tenant_id and v.id = a.current_version_id + join aihr_data_version v on v.tenant_id = a.tenant_id + and v.id = coalesce(a.candidate_version_id, + a.published_version_id, a.current_version_id) where a.tenant_id = ? and a.id <> ? and v.simhash64 is not null order by a.update_time desc limit 2000 @@ -756,13 +822,16 @@ public class AihrKnowledgeLifecycleService { throw new ServiceException("Invalid superseded asset", HttpStatus.BAD_REQUEST); } List rows = jdbcTemplate.query(""" - select id, knowledge_id, doc_id, current_version_id, lifecycle_status, data_class, + select id, knowledge_id, doc_id, coalesce(candidate_version_id, current_version_id) as current_version_id, + published_version_id, candidate_version_id, lifecycle_status, data_class, source_version, source_name from aihr_data_asset where tenant_id = ? and id = ? for update """, (rs, rowNum) -> new AssetRow(rs.getLong("id"), rs.getLong("knowledge_id"), rs.getString("doc_id"), - rs.getLong("current_version_id"), rs.getString("lifecycle_status"), rs.getString("data_class"), - rs.getString("source_version"), rs.getString("source_name")), tenantId, supersededAssetId); + rs.getLong("current_version_id"), rs.getObject("published_version_id", Long.class), + rs.getObject("candidate_version_id", Long.class), rs.getString("lifecycle_status"), + rs.getString("data_class"), rs.getString("source_version"), rs.getString("source_name")), + tenantId, supersededAssetId); if (rows.isEmpty() || !Set.of("PUBLISHED", "APPROVED").contains(rows.get(0).lifecycleStatus())) { throw new ServiceException("Superseded asset is not currently published", HttpStatus.CONFLICT); } @@ -801,18 +870,115 @@ public class AihrKnowledgeLifecycleService { private AssetRow lockAsset(String tenantId, long assetId) { List rows = jdbcTemplate.query(""" - select id, knowledge_id, doc_id, current_version_id, lifecycle_status, data_class, source_version, + select id, knowledge_id, doc_id, coalesce(candidate_version_id, current_version_id) as current_version_id, + published_version_id, candidate_version_id, lifecycle_status, data_class, source_version, source_name from aihr_data_asset where tenant_id = ? and id = ? for update """, (rs, rowNum) -> new AssetRow(rs.getLong("id"), rs.getLong("knowledge_id"), rs.getString("doc_id"), - rs.getLong("current_version_id"), rs.getString("lifecycle_status"), rs.getString("data_class"), - rs.getString("source_version"), rs.getString("source_name")), tenantId, assetId); + rs.getLong("current_version_id"), rs.getObject("published_version_id", Long.class), + rs.getObject("candidate_version_id", Long.class), rs.getString("lifecycle_status"), + rs.getString("data_class"), rs.getString("source_version"), rs.getString("source_name")), tenantId, assetId); if (rows.isEmpty()) { throw new ServiceException("Data asset not found", HttpStatus.NOT_FOUND); } return rows.get(0); } + public List 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 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 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 versionStatus(String tenantId, long versionId) { + List statuses = jdbcTemplate.queryForList(""" + select version_status from aihr_data_version where tenant_id = ? and id = ? limit 1 + """, String.class, tenantId, versionId); + return statuses.stream().findFirst(); + } + private void acceptSoftFindings(String tenantId, long assetId, long versionId, long reviewerId, List reasonCodes) { Set accepted = new LinkedHashSet<>(reasonCodes == null ? List.of() : reasonCodes); @@ -844,22 +1010,6 @@ public class AihrKnowledgeLifecycleService { command.supersedesAssetId(), tenantId, assetId); } - private int removePublishedFragments(String tenantId, long assetId) { - List ids = jdbcTemplate.queryForList(""" - select published_fragment_id from aihr_chunk_revision - where tenant_id = ? and asset_id = ? and published_fragment_id is not null - """, Long.class, tenantId, assetId); - for (Long fragmentId : ids) { - jdbcTemplate.update("delete from aihr_knowledge_fragment_locator where tenant_id = ? and fragment_id = ?", - tenantId, fragmentId); - jdbcTemplate.update("delete from aihr_knowledge_fragment where tenant_id = ? and id = ?", tenantId, fragmentId); - } - jdbcTemplate.update(""" - update aihr_chunk_revision set published_fragment_id = null where tenant_id = ? and asset_id = ? - """, tenantId, assetId); - return ids.size(); - } - private long insertPublishedFragment(String tenantId, long knowledgeId, String docId, long reviewerId, ChunkRow chunk) { KeyHolder keys = new GeneratedKeyHolder(); @@ -929,12 +1079,26 @@ public class AihrKnowledgeLifecycleService { } private void enqueueIndex(String tenantId, long assetId, long versionId, String operation, String docId) { + IndexTarget target = jdbcTemplate.queryForObject(""" + select coalesce(scope.governed_collection, ?) collection_name, + coalesce(scope.active_generation, 1) index_generation + from aihr_data_asset asset + left join aihr_knowledge_rollout_scope scope + on scope.tenant_id = asset.tenant_id and scope.knowledge_id = asset.knowledge_id + where asset.tenant_id = ? and asset.id = ? + """, (rs, rowNum) -> new IndexTarget(rs.getString("collection_name"), + rs.getLong("index_generation")), productionCollection, tenantId, assetId); + if (target == null) { + throw new ServiceException("Knowledge index target does not exist", HttpStatus.NOT_FOUND); + } jdbcTemplate.update(""" insert into aihr_index_outbox - (tenant_id, asset_id, version_id, operation, target_collection, payload_json, status, + (tenant_id, asset_id, version_id, operation, target_collection, index_generation, + payload_json, status, attempt_count, next_attempt_time, create_time, update_time) - values (?, ?, ?, ?, ?, ?, 'PENDING', 0, now(), now(), now()) - """, tenantId, assetId, versionId, operation, productionCollection, json(new IndexPayload(docId))); + values (?, ?, ?, ?, ?, ?, ?, 'PENDING', 0, now(), now(), now()) + """, tenantId, assetId, versionId, operation, target.collection(), target.generation(), + json(new IndexPayload(docId, assetId, versionId, target.generation()))); } private void addLineage(String tenantId, String fromType, String fromId, String toType, String toId, @@ -994,7 +1158,7 @@ public class AihrKnowledgeLifecycleService { } } - static String sha256(String value) { + public static String sha256(String value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") .digest(value.getBytes(StandardCharsets.UTF_8))); @@ -1058,7 +1222,14 @@ public class AihrKnowledgeLifecycleService { public record ReviewCommand(String reason, String usageType, String sourceAuthority, String sourceVersion, String applicableRegion, String applicableProject, String applicableRole, LocalDate effectiveFrom, LocalDate effectiveTo, List acceptedReasonCodes, - Long supersedesAssetId) { + Long supersedesAssetId, Long expectedVersionId) { + public ReviewCommand(String reason, String usageType, String sourceAuthority, String sourceVersion, + String applicableRegion, String applicableProject, String applicableRole, + LocalDate effectiveFrom, LocalDate effectiveTo, List acceptedReasonCodes, + Long supersedesAssetId) { + this(reason, usageType, sourceAuthority, sourceVersion, applicableRegion, applicableProject, + applicableRole, effectiveFrom, effectiveTo, acceptedReasonCodes, supersedesAssetId, null); + } } public record PublishedAsset(long assetId, long versionId, String lifecycleStatus, String indexStatus, @@ -1084,8 +1255,40 @@ public class AihrKnowledgeLifecycleService { String redactedContentPreview) { } - private record AssetRow(long id, long knowledgeId, String docId, long versionId, String lifecycleStatus, - String dataClass, String sourceVersion, String sourceName) { + public record VersionSummary(long id, int versionNo, String versionStatus, Long revisionOfVersionId, + String revisionReason, String contentSha256, String parserName, + String parserVersion, String cleaningPolicyVersion, + String classificationPolicyVersion, String semanticAnalysisStatus, + String privacyStatus, boolean published, boolean candidate, + int chunkCount, int openIssueCount, LocalDateTime createTime) { + } + + public record VersionDetail(long id, int versionNo, String versionStatus, Long revisionOfVersionId, + String revisionReason, String contentSha256, String redactedSourceName, + String redactedContent, String structureProfileJson, String extractionQualityJson, + String redactionSummaryJson, String semanticAnalysisStatus, + String semanticEmbeddingModel, Integer semanticEmbeddingDimension, + String privacyStatus, String parserName, String parserVersion, + String cleaningPolicyVersion, String classificationPolicyVersion, + boolean published, boolean candidate, List chunks, + LocalDateTime createTime) { + private VersionDetail withChunks(List value) { + return new VersionDetail(id, versionNo, versionStatus, revisionOfVersionId, revisionReason, + contentSha256, redactedSourceName, redactedContent, structureProfileJson, extractionQualityJson, + redactionSummaryJson, semanticAnalysisStatus, semanticEmbeddingModel, semanticEmbeddingDimension, + privacyStatus, parserName, parserVersion, cleaningPolicyVersion, classificationPolicyVersion, + published, candidate, value, createTime); + } + } + + public record VersionChunk(long id, int chunkIndex, String content, String contentSha256, + String headingPath, String contextPrefix, String locatorJson, + Long publishedFragmentId) { + } + + private record AssetRow(long id, long knowledgeId, String docId, long versionId, Long publishedVersionId, + Long candidateVersionId, String lifecycleStatus, String dataClass, String sourceVersion, + String sourceName) { } private record VersionGate(String semanticStatus, String privacyStatus, boolean privacyReady) { @@ -1104,7 +1307,10 @@ public class AihrKnowledgeLifecycleService { private record Snapshot(String lifecycleStatus, String dataClass, String sourceVersion) { } - private record IndexPayload(String docId) { + private record IndexPayload(String docId, long assetId, long versionId, long generation) { + } + + private record IndexTarget(String collection, long generation) { } private record DuplicateMatch(long assetId, long versionId) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeMigrationOrchestrationService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeMigrationOrchestrationService.java new file mode 100644 index 00000000..c8c08226 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeMigrationOrchestrationService.java @@ -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 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 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 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 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 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 failedIds, long nextCursor, boolean moreAvailable) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeQualityController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeQualityController.java index 53f95cca..70888c3f 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeQualityController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeQualityController.java @@ -35,6 +35,24 @@ public class AihrKnowledgeQualityController { private final AihrKnowledgeRuleSamplingService ruleSamplingService; private final AihrKnowledgePipelineRunService pipelineRunService; private final AihrKnowledgeGoldenDatasetService goldenDatasetService; + private final AihrKnowledgeRolloutService rolloutService; + private final AihrKnowledgeReviewAssistanceService reviewAssistanceService; + private final AihrKnowledgeVersionGovernanceService versionGovernanceService; + private final AihrKnowledgeRetentionService retentionService; + private final AihrKnowledgeMigrationOrchestrationService migrationOrchestrationService; + private final AihrKnowledgeShadowRolloutService shadowRolloutService; + + @GetMapping("/rollout/scopes") + public R> rolloutScopes() { + return R.ok(rolloutService.list(tenantId())); + } + + @PostMapping("/rollout/spaces/{knowledgeId}/transition") + public R transitionRollout( + @PathVariable long knowledgeId, + @RequestBody AihrKnowledgeRolloutService.TransitionCommand request) { + return R.ok(rolloutService.transition(tenantId(), knowledgeId, reviewerId(), request)); + } @GetMapping("/assets") public R> assets( @@ -53,11 +71,157 @@ public class AihrKnowledgeQualityController { return R.ok(lifecycleService.privacyPreview(tenantId(), assetId)); } + @GetMapping("/assets/{assetId}/versions") + public R> versions(@PathVariable long assetId) { + return R.ok(lifecycleService.versions(tenantId(), assetId)); + } + + @GetMapping("/assets/{assetId}/versions/{versionId}") + public R versionDetail( + @PathVariable long assetId, @PathVariable long versionId) { + return R.ok(lifecycleService.versionDetail(tenantId(), assetId, versionId)); + } + + @GetMapping("/assets/{assetId}/versions/diff") + public R 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 rollbackVersion( + @PathVariable long assetId, + @RequestBody AihrKnowledgeVersionGovernanceService.RollbackCommand request) { + return R.ok(versionGovernanceService.rollback(tenantId(), assetId, reviewerId(), request)); + } + + @GetMapping("/spaces/{knowledgeId}/generations") + public R> generations( + @PathVariable long knowledgeId) { + return R.ok(versionGovernanceService.generations(tenantId(), knowledgeId)); + } + + @GetMapping("/assets/{assetId}/impact") + public R deletionImpact(@PathVariable long assetId) { + return R.ok(retentionService.impact(tenantId(), assetId)); + } + + @PostMapping("/assets/{assetId}/purge-requests") + public R requestPurge( + @PathVariable long assetId, @RequestBody AihrKnowledgeRetentionService.PurgeRequest request) { + return R.ok(retentionService.requestPurge(tenantId(), assetId, reviewerId(), request)); + } + + @GetMapping("/purge-requests") + public R> 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 decidePurge( + @PathVariable long requestId, @RequestBody AihrKnowledgeRetentionService.Decision request) { + return R.ok(retentionService.decide(tenantId(), requestId, reviewerId(), request)); + } + + @PostMapping("/purge-requests/{requestId}/execute") + public R executePurge(@PathVariable long requestId) { + return R.ok(retentionService.execute(tenantId(), requestId, reviewerId())); + } + + @PostMapping("/reconciliation-runs") + public R reconcileStores() { + return R.ok(retentionService.reconcile(tenantId(), reviewerId())); + } + + @PostMapping("/migration-runs") + public R createMigrationRun( + @RequestBody AihrKnowledgeMigrationOrchestrationService.CreateRun request) { + return R.ok(migrationOrchestrationService.create(tenantId(), reviewerId(), request)); + } + + @GetMapping("/migration-runs") + public R> migrationRuns( + @RequestParam(defaultValue = "50") int limit) { + return R.ok(migrationOrchestrationService.runs(tenantId(), limit)); + } + + @PostMapping("/migration-runs/{runId}/next") + public R runMigrationBatch( + @PathVariable long runId) { + return R.ok(migrationOrchestrationService.runNext(tenantId(), runId, reviewerId())); + } + + @GetMapping("/migration-runs/{runId}/dead-letters") + public R> migrationDeadLetters( + @PathVariable long runId) { + return R.ok(migrationOrchestrationService.deadLetters(tenantId(), runId)); + } + + @PostMapping("/migration-dead-letters/{deadLetterId}/retry") + public R retryMigrationDeadLetter( + @PathVariable long deadLetterId) { + return R.ok(migrationOrchestrationService.retry(tenantId(), deadLetterId, reviewerId())); + } + + @PostMapping("/rollout/spaces/{knowledgeId}/generation-builds") + public R 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 refreshGenerationBuild( + @PathVariable long knowledgeId) { + return R.ok(shadowRolloutService.refreshBuild(tenantId(), knowledgeId)); + } + + @PostMapping("/rollout/spaces/{knowledgeId}/shadow-comparisons") + public R compareShadowQuery( + @PathVariable long knowledgeId, + @RequestBody AihrKnowledgeShadowRolloutService.ComparisonCommand request) { + return R.ok(shadowRolloutService.compare(tenantId(), knowledgeId, request)); + } + + @PostMapping("/rollout/spaces/{knowledgeId}/activation-gates") + public R evaluateActivationGate( + @PathVariable long knowledgeId) { + return R.ok(shadowRolloutService.evaluateGate(tenantId(), knowledgeId, reviewerId())); + } + + @PostMapping("/rollout/spaces/{knowledgeId}/activate") + public R 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 rollbackGeneration( + @PathVariable long knowledgeId, + @RequestBody AihrKnowledgeShadowRolloutService.RollbackCommand request) { + return R.ok(shadowRolloutService.rollback(tenantId(), knowledgeId, reviewerId(), request)); + } + @GetMapping("/assets/{assetId}/conflicts") public R> conflicts(@PathVariable long assetId) { return R.ok(claimService.conflicts(tenantId(), assetId)); } + @GetMapping("/assets/{assetId}/review-package") + public R reviewPackage(@PathVariable long assetId) { + return R.ok(reviewAssistanceService.reviewPackage(tenantId(), assetId)); + } + + @PostMapping("/assets/review-batches") + public R publishLowRiskBatch( + @RequestBody AihrKnowledgeReviewAssistanceService.BatchRequest request) { + return R.ok(reviewAssistanceService.publishLowRiskBatch(tenantId(), reviewerId(), request)); + } + @PostMapping("/conflicts/{conflictId}/review") public R reviewConflict( @PathVariable long conflictId, @RequestBody ConflictReviewRequest request) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeQualityMonitoringService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeQualityMonitoringService.java index 9a43dfa9..41c11482 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeQualityMonitoringService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeQualityMonitoringService.java @@ -54,19 +54,21 @@ public class AihrKnowledgeQualityMonitoringService { sum(case when lifecycle_status = 'PUBLISHED' and ( source_authority is null or source_authority = '' or source_authority = 'UNKNOWN' or source_version is null or trim(source_version) = '' - or current_version_id is null + or coalesce(published_version_id, candidate_version_id, current_version_id) is null or not exists ( select 1 from aihr_review_decision review where review.tenant_id = aihr_data_asset.tenant_id and review.asset_id = aihr_data_asset.id - and review.version_id = aihr_data_asset.current_version_id + and review.version_id = coalesce(aihr_data_asset.published_version_id, + aihr_data_asset.candidate_version_id, aihr_data_asset.current_version_id) and review.decision = 'APPROVE' and review.reviewer_type = 'HUMAN' ) or not exists ( select 1 from aihr_chunk_revision chunk where chunk.tenant_id = aihr_data_asset.tenant_id and chunk.asset_id = aihr_data_asset.id - and chunk.version_id = aihr_data_asset.current_version_id + and chunk.version_id = coalesce(aihr_data_asset.published_version_id, + aihr_data_asset.candidate_version_id, aihr_data_asset.current_version_id) and chunk.published_fragment_id is not null ) ) then 1 else 0 end) untraceable_published, @@ -74,20 +76,23 @@ public class AihrKnowledgeQualityMonitoringService { select 1 from aihr_quality_issue issue_row where issue_row.tenant_id = aihr_data_asset.tenant_id and issue_row.asset_id = aihr_data_asset.id - and issue_row.version_id = aihr_data_asset.current_version_id + and issue_row.version_id = coalesce(aihr_data_asset.published_version_id, + aihr_data_asset.candidate_version_id, aihr_data_asset.current_version_id) and issue_row.status = 'OPEN' ) then 1 else 0 end) published_open_findings, sum(case when lifecycle_status = 'PUBLISHED' and not exists ( select 1 from aihr_dataset_membership member where member.tenant_id = aihr_data_asset.tenant_id - and member.version_id = aihr_data_asset.current_version_id + and member.version_id = coalesce(aihr_data_asset.published_version_id, + aihr_data_asset.candidate_version_id, aihr_data_asset.current_version_id) and member.dataset_code = ? and member.status = 'ACTIVE' ) then 1 else 0 end) published_membership_gap, sum(case when lifecycle_status = 'PUBLISHED' and exists ( select 1 from aihr_chunk_revision chunk where chunk.tenant_id = aihr_data_asset.tenant_id and chunk.asset_id = aihr_data_asset.id - and chunk.version_id = aihr_data_asset.current_version_id + and chunk.version_id = coalesce(aihr_data_asset.published_version_id, + aihr_data_asset.current_version_id) and chunk.published_fragment_id is null ) then 1 else 0 end) published_fragment_gap from aihr_data_asset diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeRetentionService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeRetentionService.java new file mode 100644 index 00000000..a94b93ce --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeRetentionService.java @@ -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 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 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 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 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 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 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 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 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 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 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) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeReviewAssistanceService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeReviewAssistanceService.java new file mode 100644 index 00000000..18b1e961 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeReviewAssistanceService.java @@ -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 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 issues = lifecycleService.issues(tenantId, assetId).stream() + .filter(issue -> "OPEN".equals(issue.status())) + .toList(); + List duplicates = duplicateEvidence(tenantId, assetId, candidate.id()); + List 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 headings = detail.chunks().stream().map(AihrKnowledgeLifecycleService.VersionChunk::headingPath) + .filter(value -> value != null && !value.isBlank()).distinct().limit(12).toList(); + List 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 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 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 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 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(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 issues, + List 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 recommendedActions( + List 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 issues, + List duplicates, + List conflicts, + LocalDateTime versionCreatedTime) { + } + + public record ReviewSummary(String sourceName, String sourceType, String sourceAuthority, + String sourceVersion, String dataClass, String contentPreview, + List headings, int chunkCount, int issueCount, + int duplicateCount, int conflictCount) { + } + + public record ReviewSuggestion(String decision, boolean batchEligible, List reasonCodes, + List 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 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 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) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeRolloutService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeRolloutService.java new file mode 100644 index 00000000..f2b0f50a --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeRolloutService.java @@ -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 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 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 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) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeSemanticAnalysisService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeSemanticAnalysisService.java index b483612b..cca555a2 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeSemanticAnalysisService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeSemanticAnalysisService.java @@ -63,7 +63,8 @@ public class AihrKnowledgeSemanticAnalysisService { List versions = jdbcTemplate.query(""" select v.id, v.tenant_id, v.asset_id, v.redacted_content, v.semantic_retry_count from aihr_data_version v - join aihr_data_asset a on a.tenant_id = v.tenant_id and a.current_version_id = v.id + join aihr_data_asset a on a.tenant_id = v.tenant_id + and v.id = coalesce(a.candidate_version_id, a.current_version_id) where v.semantic_analysis_status in ('PENDING','FAILED') and v.privacy_status in ('CLEAN','REDACTED') and v.redacted_content is not null @@ -199,7 +200,8 @@ public class AihrKnowledgeSemanticAnalysisService { List candidates = jdbcTemplate.query(""" select a.id asset_id, v.id version_id, v.semantic_embedding_json from aihr_data_asset a - join aihr_data_version v on v.tenant_id = a.tenant_id and v.id = a.current_version_id + join aihr_data_version v on v.tenant_id = a.tenant_id + and v.id = coalesce(a.candidate_version_id, a.current_version_id) where a.tenant_id = ? and a.id <> ? and a.lifecycle_status in ('REVIEW_PENDING','APPROVED','PUBLISHED') and v.semantic_analysis_status = 'COMPLETE' diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeShadowRolloutService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeShadowRolloutService.java new file mode 100644 index 00000000..d054680a --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeShadowRolloutService.java @@ -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 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 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 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 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 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 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 legacyFragmentIds, List 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) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeVersionGovernanceService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeVersionGovernanceService.java new file mode 100644 index 00000000..873ee9b7 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeVersionGovernanceService.java @@ -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 left = byIndex(from.chunks()); + Map 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 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 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 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 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 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 byIndex( + List chunks) { + Map 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 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) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeCitationDetailService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeCitationDetailService.java index b1670762..111ba525 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeCitationDetailService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeCitationDetailService.java @@ -7,6 +7,7 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.CitationDetail; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.LocatorSummary; import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.TextSegment; +import org.dromara.aihr.knowledge.quality.AihrKnowledgeRolloutService; import org.dromara.common.core.constant.GlobalConstants; import org.dromara.common.core.constant.HttpStatus; import org.dromara.common.core.exception.ServiceException; @@ -74,25 +75,7 @@ public class AihrKnowledgeCitationDetailService { left join aihr_knowledge_fragment_locator l on l.tenant_id = f.tenant_id and l.fragment_id = f.id and l.attachment_id = a.id where f.tenant_id = ? and f.id = ? and f.knowledge_id in (%s) and k.status = 'ACTIVE' - and exists ( - select 1 - from aihr_chunk_revision governed_chunk - join aihr_data_asset governed_asset - on governed_asset.tenant_id = governed_chunk.tenant_id - and governed_asset.id = governed_chunk.asset_id - and governed_asset.current_version_id = governed_chunk.version_id - join aihr_dataset_membership governed_dataset - on governed_dataset.tenant_id = governed_chunk.tenant_id - and governed_dataset.version_id = governed_chunk.version_id - and governed_dataset.dataset_code = 'production' - and governed_dataset.status = 'ACTIVE' - where governed_chunk.tenant_id = f.tenant_id - and governed_chunk.published_fragment_id = f.id - and governed_asset.lifecycle_status = 'PUBLISHED' - and governed_asset.trust_level = 'HUMAN_VERIFIED' - and (governed_asset.effective_from is null or governed_asset.effective_from <= current_date()) - and (governed_asset.effective_to is null or governed_asset.effective_to >= current_date()) - ) + and %s and not exists ( select 1 from aihr_knowledge_source_governance invalid_governance @@ -109,7 +92,8 @@ public class AihrKnowledgeCitationDetailService { ) ) order by a.id desc limit 1 - """.formatted(String.join(",", java.util.Collections.nCopies(spaces.size(), "?"))), + """.formatted(String.join(",", java.util.Collections.nCopies(spaces.size(), "?")), + AihrKnowledgeRolloutService.servingFragmentExistsSql("f")), (rs, n) -> new Row(rs.getLong("fragment_id"), rs.getString("content"), rs.getString("doc_id"), rs.getLong("knowledge_id"), rs.getString("title"), rs.getObject("attachment_id", Long.class), rs.getString("attachment_type"), rs.getObject("oss_id", Long.class), rs.getString("source_kind"), @@ -134,27 +118,10 @@ public class AihrKnowledgeCitationDetailService { List rows = jdbcTemplate.query(""" select f.id, f.idx, f.content from aihr_knowledge_fragment f where f.tenant_id = ? and f.knowledge_id = ? and f.doc_id = ? - and exists ( - select 1 - from aihr_chunk_revision governed_chunk - join aihr_data_asset governed_asset - on governed_asset.tenant_id = governed_chunk.tenant_id - and governed_asset.id = governed_chunk.asset_id - and governed_asset.current_version_id = governed_chunk.version_id - join aihr_dataset_membership governed_dataset - on governed_dataset.tenant_id = governed_chunk.tenant_id - and governed_dataset.version_id = governed_chunk.version_id - and governed_dataset.dataset_code = 'production' - and governed_dataset.status = 'ACTIVE' - where governed_chunk.tenant_id = f.tenant_id - and governed_chunk.published_fragment_id = f.id - and governed_asset.lifecycle_status = 'PUBLISHED' - and governed_asset.trust_level = 'HUMAN_VERIFIED' - and (governed_asset.effective_from is null or governed_asset.effective_from <= current_date()) - and (governed_asset.effective_to is null or governed_asset.effective_to >= current_date()) - ) + and %s order by f.idx, f.id limit 200 - """, (rs, n) -> new TextSegment(rs.getInt("idx"), truncate(rs.getString("content"), 4_000), + """.formatted(AihrKnowledgeRolloutService.servingFragmentExistsSql("f")), + (rs, n) -> new TextSegment(rs.getInt("idx"), truncate(rs.getString("content"), 4_000), rs.getLong("id") == targetId), tenantId, knowledgeId, docId); int chars = 0; List bounded = new ArrayList<>(); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryAuditService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryAuditService.java index 220432af..d1482ed6 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryAuditService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryAuditService.java @@ -86,7 +86,8 @@ public class AihrKnowledgeQueryAuditService { from aihr_chunk_revision c join aihr_data_asset a on a.tenant_id = c.tenant_id and a.id = c.asset_id where c.tenant_id = ? and c.published_fragment_id = ? - and a.lifecycle_status = 'PUBLISHED' and a.current_version_id = c.version_id + and a.lifecycle_status = 'PUBLISHED' + and coalesce(a.published_version_id, a.current_version_id) = c.version_id limit 1 """, tenantId, requestId, rank, fragmentId, normalizedChannel(snippet.retrievalChannel()), snippet.retrievalScore(), diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java index b7282b72..3d05f47a 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java @@ -11,6 +11,7 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Citation; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.BroadcastContext; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse; +import org.dromara.aihr.knowledge.quality.AihrKnowledgeRolloutService; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Resource; import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.ConversationContext; import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.ToolResult; @@ -317,25 +318,10 @@ public class AihrKnowledgeQueryService { join aihr_knowledge_info k on k.id = a.knowledge_id and k.tenant_id = a.tenant_id where a.tenant_id = ? and a.id = ? and a.oss_id is not null and a.status = 2 and a.knowledge_id in (%s) and k.status = 'ACTIVE' - and exists ( - select 1 - from aihr_data_asset governed_asset - join aihr_dataset_membership governed_dataset - on governed_dataset.tenant_id = governed_asset.tenant_id - and governed_dataset.version_id = governed_asset.current_version_id - and governed_dataset.dataset_code = 'production' - and governed_dataset.status = 'ACTIVE' - where governed_asset.tenant_id = a.tenant_id - and governed_asset.attachment_id = a.id - and governed_asset.knowledge_id = a.knowledge_id - and governed_asset.doc_id = a.doc_id - and governed_asset.lifecycle_status = 'PUBLISHED' - and governed_asset.trust_level = 'HUMAN_VERIFIED' - and (governed_asset.effective_from is null or governed_asset.effective_from <= current_date()) - and (governed_asset.effective_to is null or governed_asset.effective_to >= current_date()) - ) + and %s limit 1 - """.formatted(placeholders(spaceIds.size())), Long.class, args.toArray()); + """.formatted(placeholders(spaceIds.size()), + AihrKnowledgeRolloutService.servingAttachmentExistsSql("a")), Long.class, args.toArray()); } if (!rows.isEmpty()) { return rows.get(0); @@ -762,7 +748,7 @@ public class AihrKnowledgeQueryService { join aihr_data_asset governed_title_asset on governed_title_asset.tenant_id = governed_title_chunk.tenant_id and governed_title_asset.id = governed_title_chunk.asset_id - and governed_title_asset.current_version_id = governed_title_chunk.version_id + and coalesce(governed_title_asset.published_version_id, governed_title_asset.current_version_id) = governed_title_chunk.version_id join aihr_data_version governed_version on governed_version.tenant_id = governed_title_chunk.tenant_id and governed_version.id = governed_title_chunk.version_id @@ -784,7 +770,7 @@ public class AihrKnowledgeQueryService { join aihr_data_asset governed_asset on governed_asset.tenant_id = governed_chunk.tenant_id and governed_asset.id = governed_chunk.asset_id - and governed_asset.current_version_id = governed_chunk.version_id + and coalesce(governed_asset.published_version_id, governed_asset.current_version_id) = governed_chunk.version_id join aihr_dataset_membership governed_dataset on governed_dataset.tenant_id = governed_chunk.tenant_id and governed_dataset.version_id = governed_chunk.version_id diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrExamService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrExamService.java index cc880994..92dad61c 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrExamService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrExamService.java @@ -706,7 +706,8 @@ public class AihrExamService { AND EXISTS ( SELECT 1 FROM aihr_chunk_revision c JOIN aihr_data_asset a ON a.tenant_id = c.tenant_id AND a.id = c.asset_id - AND a.current_version_id = c.version_id AND a.lifecycle_status = 'PUBLISHED' + AND coalesce(a.published_version_id, a.current_version_id) = c.version_id + AND a.lifecycle_status = 'PUBLISHED' AND a.trust_level = 'HUMAN_VERIFIED' JOIN aihr_dataset_membership d ON d.tenant_id = c.tenant_id AND d.version_id = c.version_id AND d.dataset_code = 'production' AND d.status = 'ACTIVE' @@ -729,7 +730,8 @@ public class AihrExamService { AND EXISTS ( SELECT 1 FROM aihr_chunk_revision c JOIN aihr_data_asset a ON a.tenant_id = c.tenant_id AND a.id = c.asset_id - AND a.current_version_id = c.version_id AND a.lifecycle_status = 'PUBLISHED' + AND coalesce(a.published_version_id, a.current_version_id) = c.version_id + AND a.lifecycle_status = 'PUBLISHED' AND a.trust_level = 'HUMAN_VERIFIED' JOIN aihr_dataset_membership d ON d.tenant_id = c.tenant_id AND d.version_id = c.version_id AND d.dataset_code = 'production' AND d.status = 'ACTIVE' diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java index cef1af54..b24b5218 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java @@ -48,6 +48,7 @@ import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier; import org.dromara.aihr.knowledge.quality.AihrKnowledgeLifecycle.UsageType; import org.dromara.aihr.knowledge.quality.AihrKnowledgeLifecycle.ReasonCode; import org.dromara.aihr.knowledge.quality.AihrKnowledgeLifecycleService; +import org.dromara.aihr.knowledge.quality.AihrKnowledgeRolloutService; import org.dromara.aihr.knowledge.quality.AihrKnowledgePrivacyService; import org.dromara.aihr.knowledge.quality.AihrKnowledgeTextProcessor; import org.dromara.aihr.personal.service.EnterpriseKnowledgeAccessPolicy.EnterpriseKnowledgeGrant; @@ -286,7 +287,7 @@ public class AihrSopSeedService { and match(f.content) against (? in natural language mode) order by score desc, f.id asc limit ? - """.formatted(placeholders, publishedLifecycleScopeSql("f")); + """.formatted(placeholders, servingLifecycleScopeSql("f")); List args = new ArrayList<>(); args.add(query); args.add(grant.tenantId()); @@ -706,7 +707,7 @@ public class AihrSopSeedService { and f.id in (%s) %s %s - """.formatted(placeholders, knowledgeScopeSql("f", allowedKnowledgeIds), publishedLifecycleScopeSql("f")); + """.formatted(placeholders, knowledgeScopeSql("f", allowedKnowledgeIds), servingLifecycleScopeSql("f")); List args = new ArrayList<>(); args.add(tenantId()); args.addAll(fragmentIds); @@ -872,6 +873,17 @@ public class AihrSopSeedService { */ @Transactional(rollbackFor = Exception.class) public UploadResponse reprocessStagedAttachment(Long attachmentId, Path stagedFile) { + return reprocessStagedAttachment(attachmentId, stagedFile, true); + } + + /** Stages a governed candidate from a ready legacy attachment without changing legacy service state. */ + @Transactional(rollbackFor = Exception.class) + public UploadResponse stageLegacyAttachmentCandidate(Long attachmentId, Path stagedFile) { + return reprocessStagedAttachment(attachmentId, stagedFile, false); + } + + private UploadResponse reprocessStagedAttachment(Long attachmentId, Path stagedFile, + boolean updateLegacyAttachment) { if (attachmentId == null || attachmentId <= 0 || stagedFile == null || !Files.isRegularFile(stagedFile)) { throw new ServiceException("MEDIA_RETRY_SOURCE_MISSING: 待重试资料不存在"); } @@ -897,7 +909,7 @@ public class AihrSopSeedService { String docId = isBlank(attachment.docId()) ? UUID.randomUUID().toString().replace("-", "") : attachment.docId(); - if (!docId.equals(attachment.docId())) { + if (updateLegacyAttachment && !docId.equals(attachment.docId())) { jdbcTemplate.update(""" update aihr_knowledge_attach set doc_id = ?, update_time = now() where tenant_id = ? and id = ? @@ -912,8 +924,10 @@ public class AihrSopSeedService { AihrKnowledgeLifecycleService.StagedAsset staged = stageRawFailureCandidate( attachment.knowledgeId(), docId, attachment.ossId(), attachment.fileName(), sourceFingerprint, reasonCode, safeExtractionEvidence(error), AihrExtractionQuality.none()); - markAttachStatus(attachment.knowledgeId(), docId, 3, - "quality-quarantined:asset=" + staged.assetId() + ":reason=" + reasonCode.name()); + if (updateLegacyAttachment) { + markAttachStatus(attachment.knowledgeId(), docId, 3, + "quality-quarantined:asset=" + staged.assetId() + ":reason=" + reasonCode.name()); + } return quarantinedUploadResponse(docId, attachment.ossId(), attachment.fileName(), attachment.spaceName(), reasonCode); } @@ -924,8 +938,10 @@ public class AihrSopSeedService { AihrKnowledgeLifecycleService.StagedAsset staged = stageRawFailureCandidate( attachment.knowledgeId(), docId, attachment.ossId(), attachment.fileName(), sourceFingerprint, reasonCode, "No usable text was extracted during reprocessing", extracted.quality()); - markAttachStatus(attachment.knowledgeId(), docId, 3, - "quality-quarantined:asset=" + staged.assetId() + ":reason=" + reasonCode.name()); + if (updateLegacyAttachment) { + markAttachStatus(attachment.knowledgeId(), docId, 3, + "quality-quarantined:asset=" + staged.assetId() + ":reason=" + reasonCode.name()); + } return quarantinedUploadResponse(docId, attachment.ossId(), attachment.fileName(), attachment.spaceName(), reasonCode); } @@ -939,9 +955,11 @@ public class AihrSopSeedService { AihrKnowledgeLifecycleService.StagedAsset staged = stageCandidate( attachment.knowledgeId(), docId, attachment.ossId(), attachment.fileName(), content, fragments, fingerprint, extracted.quality(), chunkPlan.locators()); - updateOssInsight(attachment.ossId(), insight, fingerprint); - markAttachStatus(attachment.knowledgeId(), docId, 2, - "review-pending:asset=" + staged.assetId()); + if (updateLegacyAttachment) { + updateOssInsight(attachment.ossId(), insight, fingerprint); + markAttachStatus(attachment.knowledgeId(), docId, 2, + "review-pending:asset=" + staged.assetId()); + } List snippets = fragments.stream() .limit(3) @@ -1133,7 +1151,7 @@ public class AihrSopSeedService { return new VectorIndexStatusResponse( runtime.modelName(), expectedDimension, - qdrantCollection(), + governedQdrantCollection(), qdrant.dimension(), qdrant.points(), db.fragments(), @@ -2220,7 +2238,8 @@ public class AihrSopSeedService { and a.index_status = 'READY' then a.id end) indexed_assets from aihr_data_asset a left join aihr_chunk_revision c on c.tenant_id = a.tenant_id - and c.asset_id = a.id and c.version_id = a.current_version_id + and c.asset_id = a.id + and c.version_id = coalesce(a.published_version_id, a.current_version_id) left join aihr_knowledge_fragment f on f.tenant_id = c.tenant_id and f.id = c.published_fragment_id where a.tenant_id = ? @@ -2632,16 +2651,26 @@ public class AihrSopSeedService { } public int vectorizePublishedDocument(long knowledgeId, String docId) { + AihrKnowledgeRolloutService.ModeGeneration rollout = rolloutModeGeneration(knowledgeId); + return vectorizePublishedDocument(knowledgeId, docId, null, + firstNonBlank(rollout.governedCollection(), governedQdrantCollection()), rollout.activeGeneration()); + } + + public int vectorizePublishedDocument(long knowledgeId, String docId, Long expectedVersionId, + String targetCollection, long targetGeneration) { List rows = jdbcTemplate.query(""" select i.name, f.content from aihr_knowledge_fragment f join aihr_knowledge_info i on i.tenant_id = f.tenant_id and i.id = f.knowledge_id + left join aihr_data_asset a on a.tenant_id = f.tenant_id + and a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id where f.tenant_id = ? and f.knowledge_id = ? and f.doc_id = ? %s + and (? is null or a.published_version_id = ?) order by f.idx """.formatted(publishedLifecycleScopeSql("f")), (rs, rowNum) -> new DocumentVectorRow(rs.getString(1), rs.getString(2)), - tenantId(), knowledgeId, docId); + tenantId(), knowledgeId, docId, expectedVersionId, expectedVersionId); if (rows.isEmpty()) { throw new ServiceException("Published document has no authorized fragments", 409); } @@ -2660,7 +2689,8 @@ public class AihrSopSeedService { data.embeddings().get(index), data.modelName(), tenantId(), knowledgeId, docId, index + 1); } try { - upsertQdrant(knowledgeId, rows.get(0).category(), docId, fragments, data.embeddings(), data.modelName()); + upsertQdrant(knowledgeId, rows.get(0).category(), docId, fragments, data.embeddings(), data.modelName(), + expectedVersionId, targetCollection, targetGeneration); } catch (Exception ex) { throw new ServiceException("Vector index upsert failed").setDetailMessage(ex.getMessage()); } @@ -2668,11 +2698,18 @@ public class AihrSopSeedService { } public void deleteVectorDocument(long knowledgeId, String docId) { + deleteVectorDocument(knowledgeId, docId, governedQdrantCollection(), null); + } + + public void deleteVectorDocument(long knowledgeId, String docId, String targetCollection, Long generation) { try { ObjectNode body = objectMapper.createObjectNode(); - body.set("filter", qdrantFilter(knowledgeId, docId, null)); + body.set("filter", generation == null + ? qdrantFilter(knowledgeId, docId, null) + : qdrantProductionFilterForSpaces(Set.of(knowledgeId), docId, null, generation)); HttpResponse response = qdrantRequest( - "POST", "/collections/" + qdrantCollection() + "/points/delete?wait=true", body); + "POST", "/collections/" + firstNonBlank(targetCollection, governedQdrantCollection()) + + "/points/delete?wait=true", body); if (response.statusCode() != 404 && !ok(response.statusCode())) { throw new IllegalStateException("qdrant delete HTTP " + response.statusCode()); } @@ -3520,6 +3557,10 @@ public class AihrSopSeedService { return "and " + publishedLifecycleExistsSql(fragmentAlias); } + private static String servingLifecycleScopeSql(String fragmentAlias) { + return "and " + AihrKnowledgeRolloutService.servingFragmentExistsSql(fragmentAlias); + } + private static String publishedLifecycleExistsSql(String fragmentAlias) { return """ exists ( @@ -3528,7 +3569,7 @@ public class AihrSopSeedService { join aihr_data_asset governed_asset on governed_asset.tenant_id = governed_chunk.tenant_id and governed_asset.id = governed_chunk.asset_id - and governed_asset.current_version_id = governed_chunk.version_id + and coalesce(governed_asset.published_version_id, governed_asset.current_version_id) = governed_chunk.version_id join aihr_dataset_membership governed_dataset on governed_dataset.tenant_id = governed_chunk.tenant_id and governed_dataset.version_id = governed_chunk.version_id @@ -3644,7 +3685,7 @@ public class AihrSopSeedService { order by score desc, f.idx asc limit ? """.formatted(knowledgeScopeSql("f", allowedKnowledgeIds), - formalSourceScopeSql("f", formalPolicyOnly), publishedLifecycleScopeSql("f")); + formalSourceScopeSql("f", formalPolicyOnly), servingLifecycleScopeSql("f")); List args = new ArrayList<>(); args.add(queryText); args.add(tenantId()); @@ -3707,7 +3748,7 @@ public class AihrSopSeedService { order by score desc, f.idx asc limit ? """.formatted(String.join(" + ", scoreParts), knowledgeScopeSql("f", allowedKnowledgeIds), - formalSourceScopeSql("f", formalPolicyOnly), publishedLifecycleScopeSql("f"), + formalSourceScopeSql("f", formalPolicyOnly), servingLifecycleScopeSql("f"), String.join(" or ", whereParts)); List args = new ArrayList<>(); terms.stream().map(AihrSopSeedService::likePattern).forEach(args::add); @@ -3755,14 +3796,16 @@ public class AihrSopSeedService { %s %s %s + and f.knowledge_id = ? and f.doc_id = ? and f.idx = ? limit 1 """.formatted(knowledgeScopeSql("f", allowedKnowledgeIds), - formalSourceScopeSql("f", formalPolicyOnly), publishedLifecycleScopeSql("f")); + formalSourceScopeSql("f", formalPolicyOnly), servingLifecycleScopeSql("f")); List args = new ArrayList<>(); args.add(tenantId()); addKnowledgeScopeArgs(args, allowedKnowledgeIds); + args.add(match.knowledgeId()); args.add(match.docId()); args.add(match.idx()); List 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 fragments, List 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 fragments, + List embeddings, String modelName, Long expectedVersionId, + String targetCollection, long targetGeneration) throws Exception { if (fragments.isEmpty() || embeddings.isEmpty()) { return; } @@ -4293,9 +4344,41 @@ public class AihrSopSeedService { if (!firstVector.isArray() || firstVector.size() == 0) { return; } - ensureQdrantCollection(firstVector.size()); + String governedCollection = firstNonBlank(targetCollection, governedQdrantCollection()); + ensureQdrantCollection(governedCollection, firstVector.size()); + + long generation = Math.max(1L, targetGeneration); + Map 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 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 deleteResponse = qdrantRequest( + "POST", "/collections/" + governedCollection + "/points/delete?wait=true", deleteBody); + if (deleteResponse.statusCode() != 404 && !ok(deleteResponse.statusCode())) { + throw new IllegalStateException("qdrant generation cleanup HTTP " + deleteResponse.statusCode()); + } ArrayNode points = objectMapper.createArrayNode(); + Map pointIds = new LinkedHashMap<>(); int size = Math.min(fragments.size(), embeddings.size()); for (int i = 0; i < size; i++) { JsonNode vector = objectMapper.readTree(embeddings.get(i)); @@ -4312,9 +4395,29 @@ public class AihrSopSeedService { payload.put("dataset_code", "production"); payload.put("lifecycle_status", "PUBLISHED"); payload.put("trust_level", "HUMAN_VERIFIED"); + payload.put("index_generation", generation); + payload.put("content_sha256", AihrKnowledgeLifecycleService.sha256(fragments.get(i))); + + GovernedVectorLineage lineage = governedLineage.get(i + 1); + String pointId = qdrantPointId(knowledgeId, docId, i + 1); + if (lineage != null) { + payload.put("asset_id", lineage.assetId()); + payload.put("version_id", lineage.versionId()); + payload.put("chunk_revision_id", lineage.chunkRevisionId()); + if (lineage.publishedFragmentId() != null) { + payload.put("fragment_id", lineage.publishedFragmentId()); + } + payload.put("content_sha256", lineage.contentSha256()); + if (!isBlank(lineage.locatorJson())) { + payload.set("locator", objectMapper.readTree(lineage.locatorJson())); + } + pointId = governedQdrantPointId(lineage.assetId(), lineage.versionId(), + lineage.chunkRevisionId(), generation); + pointIds.put(lineage.chunkRevisionId(), pointId); + } ObjectNode point = objectMapper.createObjectNode(); - point.put("id", qdrantPointId(knowledgeId, docId, i + 1)); + point.put("id", pointId); point.set("vector", vector); point.set("payload", payload); points.add(point); @@ -4325,10 +4428,14 @@ public class AihrSopSeedService { ObjectNode body = objectMapper.createObjectNode(); body.set("points", points); - HttpResponse response = qdrantRequest("PUT", "/collections/" + qdrantCollection() + "/points?wait=true", body); + HttpResponse response = qdrantRequest("PUT", "/collections/" + governedCollection + "/points?wait=true", body); if (!ok(response.statusCode())) { throw new IllegalStateException("qdrant upsert HTTP " + response.statusCode()); } + pointIds.forEach((chunkRevisionId, pointId) -> jdbcTemplate.update(""" + update aihr_chunk_revision set index_generation = ?, vector_point_id = ? + where tenant_id = ? and id = ? + """, generation, pointId, tenantId(), chunkRevisionId)); } private void deleteQdrantDoc(long knowledgeId, String docId) { @@ -4339,7 +4446,7 @@ public class AihrSopSeedService { try { ObjectNode body = objectMapper.createObjectNode(); body.set("filter", qdrantFilter(knowledgeId, docId, null)); - HttpResponse response = qdrantRequest("POST", "/collections/" + qdrantCollection() + "/points/delete?wait=true", body); + HttpResponse response = qdrantRequest("POST", "/collections/" + governedQdrantCollection() + "/points/delete?wait=true", body); if (response.statusCode() == 404) { return; } @@ -4370,14 +4477,46 @@ public class AihrSopSeedService { } private List queryQdrant(String category, String embeddingJson, int limit, Set allowedKnowledgeIds) throws Exception { + List scopes = rolloutVectorScopes(allowedKnowledgeIds); + List matches = new ArrayList<>(); + Set legacySpaces = new LinkedHashSet<>(); + Map> 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> 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 queryQdrantCollection(String collection, String category, String embeddingJson, + int limit, Set knowledgeIds, + Long generation) throws Exception { ObjectNode body = objectMapper.createObjectNode(); body.set("query", objectMapper.readTree(embeddingJson)); - body.set("filter", qdrantProductionFilterForSpaces(allowedKnowledgeIds, null, category)); + body.set("filter", generation == null + ? qdrantFilterForSpaces(knowledgeIds, null, category) + : qdrantProductionFilterForSpaces(knowledgeIds, null, category, generation)); body.put("limit", limit); body.put("with_payload", true); body.put("with_vector", false); - HttpResponse response = qdrantRequest("POST", "/collections/" + qdrantCollection() + "/points/query", body); + HttpResponse response = qdrantRequest("POST", "/collections/" + collection + "/points/query", body); if (!ok(response.statusCode())) { return List.of(); } @@ -4388,15 +4527,78 @@ public class AihrSopSeedService { List matches = new ArrayList<>(); for (JsonNode point : points) { JsonNode payload = point.path("payload"); + long knowledgeId = payload.path("knowledge_id").asLong(0L); String docId = payload.path("doc_id").asText(""); int idx = payload.path("idx").asInt(0); - if (!docId.isBlank() && idx > 0) { - matches.add(new VectorMatch(docId, idx, point.path("score").asDouble())); + if (knowledgeId > 0 && !docId.isBlank() && idx > 0) { + matches.add(new VectorMatch(knowledgeId, docId, idx, point.path("score").asDouble())); } } return matches; } + private List rolloutVectorScopes(Set allowedKnowledgeIds) { + String idScope = allowedKnowledgeIds == null || allowedKnowledgeIds.isEmpty() + ? "" : "and k.id in (" + String.join(",", + java.util.Collections.nCopies(allowedKnowledgeIds.size(), "?")) + ")"; + List 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 legacyVectorScopes(Set 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 rows = jdbcTemplate.query(""" + select mode, governed_collection, active_generation + from aihr_knowledge_rollout_scope + where tenant_id = ? and knowledge_id = ? limit 1 + """, (rs, rowNum) -> new AihrKnowledgeRolloutService.ModeGeneration( + rs.getString("mode"), rs.getString("governed_collection"), + rs.getLong("active_generation")), tenantId(), knowledgeId); + if (!rows.isEmpty()) { + return rows.get(0); + } + } catch (DataAccessException ignored) { + // Schema can be installed before or together with the compatibility JAR. + } + return new AihrKnowledgeRolloutService.ModeGeneration("LEGACY", + AihrKnowledgeRolloutService.DEFAULT_GOVERNED_COLLECTION, 1L); + } + private VectorDbStats vectorDbStats() { try { return jdbcTemplate.queryForObject(""" @@ -4438,7 +4640,7 @@ public class AihrSopSeedService { private QdrantStats qdrantStats() { try { - HttpResponse response = qdrantRequest("GET", "/collections/" + qdrantCollection(), null); + HttpResponse response = qdrantRequest("GET", "/collections/" + governedQdrantCollection(), null); if (response.statusCode() == 404) { return new QdrantStats(null, 0L, "Qdrant collection 不存在"); } @@ -4456,8 +4658,8 @@ public class AihrSopSeedService { } } - private void ensureQdrantCollection(int vectorSize) throws Exception { - HttpResponse current = qdrantRequest("GET", "/collections/" + qdrantCollection(), null); + private void ensureQdrantCollection(String collection, int vectorSize) throws Exception { + HttpResponse current = qdrantRequest("GET", "/collections/" + collection, null); if (ok(current.statusCode())) { int currentSize = objectMapper.readTree(current.body()) .path("result") @@ -4480,7 +4682,7 @@ public class AihrSopSeedService { vectors.put("distance", "Cosine"); ObjectNode body = objectMapper.createObjectNode(); body.set("vectors", vectors); - HttpResponse created = qdrantRequest("PUT", "/collections/" + qdrantCollection(), body); + HttpResponse created = qdrantRequest("PUT", "/collections/" + collection, body); if (!ok(created.statusCode())) { throw new IllegalStateException("qdrant create collection HTTP " + created.statusCode()); } @@ -4490,7 +4692,7 @@ public class AihrSopSeedService { try { ObjectNode body = objectMapper.createObjectNode(); body.set("filter", qdrantProductionFilterForSpaces(null, null, null)); - HttpResponse response = qdrantRequest("POST", "/collections/" + qdrantCollection() + "/points/delete?wait=true", body); + HttpResponse response = qdrantRequest("POST", "/collections/" + governedQdrantCollection() + "/points/delete?wait=true", body); if (response.statusCode() == 404) { return; } @@ -4507,7 +4709,7 @@ public class AihrSopSeedService { ObjectNode body = objectMapper.createObjectNode(); body.set("filter", qdrantProductionFilterForSpaces(null, null, null)); body.put("exact", true); - HttpResponse response = qdrantRequest("POST", "/collections/" + qdrantCollection() + "/points/count", body); + HttpResponse response = qdrantRequest("POST", "/collections/" + governedQdrantCollection() + "/points/count", body); if (!ok(response.statusCode())) { return null; } @@ -4539,11 +4741,19 @@ public class AihrSopSeedService { } private ObjectNode qdrantProductionFilterForSpaces(Set knowledgeIds, String docId, String category) { + return qdrantProductionFilterForSpaces(knowledgeIds, docId, category, null); + } + + private ObjectNode qdrantProductionFilterForSpaces(Set knowledgeIds, String docId, String category, + Long generation) { ObjectNode filter = qdrantFilterForSpaces(knowledgeIds, docId, category); ArrayNode must = (ArrayNode) filter.path("must"); must.add(qdrantMatch("dataset_code", "production")); must.add(qdrantMatch("lifecycle_status", "PUBLISHED")); must.add(qdrantMatch("trust_level", "HUMAN_VERIFIED")); + if (generation != null) { + must.add(qdrantMatch("index_generation", generation)); + } return filter; } @@ -4968,8 +5178,15 @@ public class AihrSopSeedService { return normalizeBaseUrl(firstNonBlank(System.getProperty("aihr.qdrant.url"), System.getenv("AIHR_QDRANT_URL"), "http://127.0.0.1:6333")); } - private static String qdrantCollection() { - return firstNonBlank(System.getProperty("aihr.qdrant.collection"), System.getenv("AIHR_QDRANT_COLLECTION"), "aihr_knowledge"); + private static String legacyQdrantCollection() { + return firstNonBlank(System.getProperty("aihr.qdrant.collection"), + System.getenv("AIHR_QDRANT_COLLECTION"), "aihr_knowledge"); + } + + private static String governedQdrantCollection() { + return firstNonBlank(System.getProperty("aihr.qdrant.governed-collection"), + System.getenv("AIHR_QDRANT_GOVERNED_COLLECTION"), + AihrKnowledgeRolloutService.DEFAULT_GOVERNED_COLLECTION); } private static String qdrantApiKey() { @@ -4980,6 +5197,11 @@ public class AihrSopSeedService { return UUID.nameUUIDFromBytes((tenantId() + ":" + knowledgeId + ":" + docId + ":" + idx).getBytes(StandardCharsets.UTF_8)).toString(); } + private String governedQdrantPointId(long assetId, long versionId, long chunkRevisionId, long generation) { + return UUID.nameUUIDFromBytes((tenantId() + ":governed:" + assetId + ":" + versionId + ":" + + chunkRevisionId + ":" + generation).getBytes(StandardCharsets.UTF_8)).toString(); + } + private static boolean ok(int statusCode) { return statusCode >= 200 && statusCode < 300; } @@ -5185,6 +5407,10 @@ public class AihrSopSeedService { private record DocumentVectorRow(String category, String content) { } + private record GovernedVectorLineage(long assetId, long versionId, long chunkRevisionId, + String contentSha256, String locatorJson, Long publishedFragmentId) { + } + private record DuplicateHit(long attachId, long knowledgeId, String docId, long ossId, String matchType) { } @@ -5198,7 +5424,10 @@ public class AihrSopSeedService { private record VideoMarker(boolean frame, long startMs, Long endMs) { } - private record VectorMatch(String docId, int idx, double score) { + private record VectorMatch(long knowledgeId, String docId, int idx, double score) { + } + + private record RolloutVectorScope(long knowledgeId, String mode, String collection, long generation) { } private record LocalImportPlan(Path root, String directory, String category, List files) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeCitationDetailServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeCitationDetailServiceTest.java index 3c527c64..6d793734 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeCitationDetailServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeCitationDetailServiceTest.java @@ -24,7 +24,7 @@ import static org.mockito.Mockito.when; @Tag("dev") class AihrKnowledgeCitationDetailServiceTest { @Test - void detailAndAdjacentSegmentsRequireCurrentPublishedLifecycle() throws Exception { + void detailAndAdjacentSegmentsUseTheSharedRolloutLifecyclePredicate() throws Exception { Path source = Path.of( "src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeCitationDetailService.java"); if (!Files.exists(source)) { @@ -33,10 +33,7 @@ class AihrKnowledgeCitationDetailServiceTest { } String code = Files.readString(source); - assertTrue(code.split("governed_chunk.published_fragment_id = f.id", -1).length - 1 >= 2); - assertTrue(code.contains("governed_asset.current_version_id = governed_chunk.version_id")); - assertTrue(code.contains("governed_asset.lifecycle_status = 'PUBLISHED'")); - assertTrue(code.contains("governed_dataset.dataset_code = 'production'")); + assertTrue(code.split("servingFragmentExistsSql", -1).length - 1 >= 2); } @Test diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java index 3a043a76..a3e79306 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java @@ -55,7 +55,7 @@ import static org.mockito.ArgumentMatchers.eq; class AihrKnowledgeQueryServiceTest { @Test - void citationHydrationAndResourceDownloadsRequirePublishedLifecycle() throws Exception { + void resourceDownloadsUseTheSharedRolloutLifecyclePredicate() throws Exception { Path source = Path.of( "src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java"); if (!Files.exists(source)) { @@ -64,12 +64,7 @@ class AihrKnowledgeQueryServiceTest { } String code = Files.readString(source); - assertTrue(code.contains("governed_chunk.published_fragment_id = f.id")); - assertTrue(code.contains("governed_asset.attachment_id = a.id")); - assertTrue(code.contains("governed_asset.lifecycle_status = 'PUBLISHED'")); - assertTrue(code.contains("governed_asset.trust_level = 'HUMAN_VERIFIED'")); - assertTrue(code.contains("governed_dataset.dataset_code = 'production'")); - assertTrue(code.contains("governed_dataset.status = 'ACTIVE'")); + assertTrue(code.contains("AihrKnowledgeRolloutService.servingAttachmentExistsSql(\"a\")")); } @Test diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeResourceDownloadServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeResourceDownloadServiceTest.java index 18959f56..480cad1e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeResourceDownloadServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeResourceDownloadServiceTest.java @@ -19,6 +19,7 @@ import static org.mockito.ArgumentMatchers.startsWith; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @Tag("dev") @@ -59,7 +60,7 @@ class AihrKnowledgeResourceDownloadServiceTest { verify(redisson).getBucket(GlobalConstants.GLOBAL_REDIS_KEY + "aihr:knowledge:resource-download:321:" + ticket); verify(response).setHeader("Cache-Control", "no-store"); verify(response).setHeader("Referrer-Policy", "no-referrer"); - verify(ossService).download(777L, response); + verify(ossService).downloadRange(777L, null, response); } @Test @@ -74,6 +75,6 @@ class AihrKnowledgeResourceDownloadServiceTest { verify(response).sendError(HttpServletResponse.SC_NOT_FOUND, "资料不存在或无权访问"); verify(redisson, never()).getBucket(anyString()); - verify(ossService, never()).download(org.mockito.ArgumentMatchers.anyLong(), eq(response)); + verifyNoInteractions(ossService); } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrDataQualityLifecycleSchemaTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrDataQualityLifecycleSchemaTest.java index 8b17fdae..266bfc69 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrDataQualityLifecycleSchemaTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrDataQualityLifecycleSchemaTest.java @@ -36,6 +36,20 @@ class AihrDataQualityLifecycleSchemaTest { "aihr_20260812_pipeline_run_sampling_mysql8.sql"); private static final Path GOLDEN_CALIBRATION_MIGRATION = Path.of("..", "..", "script", "sql", "update", "aihr_20260813_golden_calibration_mysql8.sql"); + private static final Path ROLLOUT_MIGRATION = Path.of("..", "..", "script", "sql", "update", + "aihr_20260815_knowledge_rollout_compat_mysql8.sql"); + private static final Path VERSION_POINTER_MIGRATION = Path.of("..", "..", "script", "sql", "update", + "aihr_20260816_knowledge_version_pointers_mysql8.sql"); + private static final Path REVIEW_ASSISTANCE_MIGRATION = Path.of("..", "..", "script", "sql", "update", + "aihr_20260817_knowledge_review_assistance_mysql8.sql"); + private static final Path GENERATION_MIGRATION = Path.of("..", "..", "script", "sql", "update", + "aihr_20260818_knowledge_generation_revision_mysql8.sql"); + private static final Path RETENTION_MIGRATION = Path.of("..", "..", "script", "sql", "update", + "aihr_20260819_knowledge_retention_deletion_mysql8.sql"); + private static final Path MIGRATION_ORCHESTRATION = Path.of("..", "..", "script", "sql", "update", + "aihr_20260820_knowledge_migration_orchestration_mysql8.sql"); + private static final Path SHADOW_GATE_MIGRATION = Path.of("..", "..", "script", "sql", "update", + "aihr_20260821_knowledge_shadow_gate_mysql8.sql"); private static final Path LIFECYCLE_SERVICE = Path.of("src", "main", "java", "org", "dromara", "aihr", "knowledge", "quality", "AihrKnowledgeLifecycleService.java"); @@ -196,4 +210,78 @@ class AihrDataQualityLifecycleSchemaTest { assertThat(sql.toLowerCase()).doesNotContain("update aihr_processing_rule"); assertThat(sql.toLowerCase()).doesNotContain("lifecycle_status = 'published'"); } + + @Test + void rolloutMigrationDefaultsEveryUnconfiguredSpaceToLegacyWithoutDataMutation() throws IOException { + String sql = Files.readString(ROLLOUT_MIGRATION); + + assertThat(sql).contains("`aihr_knowledge_rollout_scope`", "`aihr_knowledge_rollout_transition`", + "DEFAULT 'LEGACY'", "'LEGACY','SHADOW','ENFORCED'", "governed_collection", + "active_generation", "reviewer_id", "readiness_json"); + assertThat(sql.toLowerCase()).doesNotContain("update `aihr_knowledge_attach`"); + assertThat(sql.toLowerCase()).doesNotContain("insert into `aihr_knowledge_rollout_scope`"); + } + + @Test + void versionPointerMigrationSeparatesCandidateFromPublishedWithoutRewritingContent() throws IOException { + String sql = Files.readString(VERSION_POINTER_MIGRATION); + + assertThat(sql).contains("published_version_id", "candidate_version_id", "version_status", + "revision_of_version_id", "PUBLISHED", "SUPERSEDED", "WITHDRAWN"); + assertThat(sql).contains("WHEN published_version_id IS NULL AND lifecycle_status = 'PUBLISHED'", + "WHEN candidate_version_id IS NULL AND lifecycle_status IN"); + assertThat(sql.toLowerCase()).doesNotContain("update `aihr_knowledge_fragment`"); + assertThat(sql.toLowerCase()).doesNotContain("delete from"); + } + + @Test + void reviewAssistanceMigrationKeepsSuggestionsSeparateFromHumanDecisions() throws IOException { + String sql = Files.readString(REVIEW_ASSISTANCE_MIGRATION); + + assertThat(sql).contains("`aihr_review_assistance`", "`aihr_review_batch`", + "`aihr_review_batch_item`", "risk_level", "policy_version", "reviewer_id", "batch_key"); + assertThat(sql.toLowerCase()).doesNotContain("update aihr_data_asset"); + assertThat(sql.toLowerCase()).doesNotContain("insert into aihr_review_decision"); + } + + @Test + void generationMigrationPreservesImmutableVersionsAndVectorLineage() throws IOException { + String sql = Files.readString(GENERATION_MIGRATION); + + assertThat(sql).contains("`aihr_knowledge_generation`", "`aihr_generation_version`", + "`aihr_version_diff`", "`aihr_version_rollback`", "index_generation", "vector_point_id"); + assertThat(sql.toLowerCase()).doesNotContain("delete from aihr_chunk_revision"); + } + + @Test + void retentionMigrationCreatesDualControlWorkflowWithoutDeletingExistingAssets() throws IOException { + String sql = Files.readString(RETENTION_MIGRATION); + + assertThat(sql).contains("retention_class", "legal_hold", "delete_state", + "`aihr_deletion_request`", "requester_id", "approver_id", "execute_after", + "`aihr_reconciliation_run`", "`aihr_reconciliation_issue`"); + assertThat(sql.toLowerCase()).doesNotContain("delete from aihr_data_asset"); + assertThat(sql.toLowerCase()).doesNotContain("update aihr_data_asset set delete_state"); + } + + @Test + void migrationOrchestrationPersistsCursorBatchAndDeadLetterEvidence() throws IOException { + String sql = Files.readString(MIGRATION_ORCHESTRATION); + + assertThat(sql).contains("`aihr_migration_run`", "cursor_attachment_id", "manifest_sha256", + "verified_dry_run_id", "idx_aihr_migration_verified_dry_run", + "`aihr_migration_batch`", "from_cursor", "to_cursor", "`aihr_migration_dead_letter`", + "attempt_count", "next_attempt_time"); + assertThat(sql.toLowerCase()).doesNotContain("update aihr_knowledge_attach"); + } + + @Test + void shadowGateMigrationStoresComparisonAndHumanActivationEvidence() throws IOException { + String sql = Files.readString(SHADOW_GATE_MIGRATION); + + assertThat(sql).contains("`aihr_shadow_comparison`", "top1_agreement", "overlap_at_5", + "`aihr_activation_gate`", "threshold_json", "evidence_json", + "`aihr_generation_activation`", "rollback_deadline", "ACTIVATE", "ROLLBACK"); + assertThat(sql.toLowerCase()).doesNotContain("update aihr_knowledge_rollout_scope"); + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeGovernanceSafetyTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeGovernanceSafetyTest.java new file mode 100644 index 00000000..7713952e --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeGovernanceSafetyTest.java @@ -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); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeLegacyMigrationServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeLegacyMigrationServiceTest.java index a8d22b2e..07efcd67 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeLegacyMigrationServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeLegacyMigrationServiceTest.java @@ -10,6 +10,8 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import java.sql.ResultSet; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -24,6 +26,18 @@ import static org.mockito.Mockito.when; @Tag("dev") class AihrKnowledgeLegacyMigrationServiceTest { + @Test + void rawMigrationUsesTheNonMutatingCandidatePath() throws Exception { + Path source = Path.of("src", "main", "java", "org", "dromara", "aihr", "knowledge", + "quality", "AihrKnowledgeLegacyMigrationService.java"); + String code = Files.readString(source); + + assertFalse(code.contains("sopSeedService.reprocessStagedAttachment")); + org.assertj.core.api.Assertions.assertThat(code) + .contains("sopSeedService.stageLegacyAttachmentCandidate", "LOW_RISK", "QUARANTINE_FIRST", + "o.oss_id is not null", "o.oss_id is null"); + } + @Test @SuppressWarnings({"rawtypes", "unchecked"}) void previewReportsRawAvailabilityWithoutStagingAnything() throws Exception { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeReviewAssistanceServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeReviewAssistanceServiceTest.java new file mode 100644 index 00000000..7e8fc8df --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeReviewAssistanceServiceTest.java @@ -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()); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeRolloutServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeRolloutServiceTest.java new file mode 100644 index 00000000..89a4f8ba --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeRolloutServiceTest.java @@ -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(); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java index 98355013..c3d8674a 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java @@ -646,7 +646,10 @@ public class AihrSopSeedServiceTest { assertTrue(code.contains("payload.put(\"dataset_code\", \"production\")")); assertTrue(code.contains("payload.put(\"lifecycle_status\", \"PUBLISHED\")")); assertTrue(code.contains("payload.put(\"trust_level\", \"HUMAN_VERIFIED\")")); - assertTrue(code.contains("qdrantProductionFilterForSpaces(allowedKnowledgeIds, null, category)")); + assertTrue(code.contains("payload.put(\"index_generation\", generation)")); + assertTrue(code.contains("qdrantProductionFilterForSpaces(knowledgeIds, null, category, generation)")); + assertTrue(code.contains("legacyQdrantCollection()")); + assertTrue(code.contains("governedQdrantCollection()")); } @Test diff --git a/backend/script/sql/update/aihr_20260815_knowledge_rollout_compat_mysql8.sql b/backend/script/sql/update/aihr_20260815_knowledge_rollout_compat_mysql8.sql new file mode 100644 index 00000000..facb91bc --- /dev/null +++ b/backend/script/sql/update/aihr_20260815_knowledge_rollout_compat_mysql8.sql @@ -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; diff --git a/backend/script/sql/update/aihr_20260816_knowledge_version_pointers_mysql8.sql b/backend/script/sql/update/aihr_20260816_knowledge_version_pointers_mysql8.sql new file mode 100644 index 00000000..ed8dc9ac --- /dev/null +++ b/backend/script/sql/update/aihr_20260816_knowledge_version_pointers_mysql8.sql @@ -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; diff --git a/backend/script/sql/update/aihr_20260817_knowledge_review_assistance_mysql8.sql b/backend/script/sql/update/aihr_20260817_knowledge_review_assistance_mysql8.sql new file mode 100644 index 00000000..5208ecf4 --- /dev/null +++ b/backend/script/sql/update/aihr_20260817_knowledge_review_assistance_mysql8.sql @@ -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; diff --git a/backend/script/sql/update/aihr_20260818_knowledge_generation_revision_mysql8.sql b/backend/script/sql/update/aihr_20260818_knowledge_generation_revision_mysql8.sql new file mode 100644 index 00000000..d0441026 --- /dev/null +++ b/backend/script/sql/update/aihr_20260818_knowledge_generation_revision_mysql8.sql @@ -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; diff --git a/backend/script/sql/update/aihr_20260819_knowledge_retention_deletion_mysql8.sql b/backend/script/sql/update/aihr_20260819_knowledge_retention_deletion_mysql8.sql new file mode 100644 index 00000000..578c7928 --- /dev/null +++ b/backend/script/sql/update/aihr_20260819_knowledge_retention_deletion_mysql8.sql @@ -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; diff --git a/backend/script/sql/update/aihr_20260820_knowledge_migration_orchestration_mysql8.sql b/backend/script/sql/update/aihr_20260820_knowledge_migration_orchestration_mysql8.sql new file mode 100644 index 00000000..b145b78b --- /dev/null +++ b/backend/script/sql/update/aihr_20260820_knowledge_migration_orchestration_mysql8.sql @@ -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; diff --git a/backend/script/sql/update/aihr_20260821_knowledge_shadow_gate_mysql8.sql b/backend/script/sql/update/aihr_20260821_knowledge_shadow_gate_mysql8.sql new file mode 100644 index 00000000..14a46ed5 --- /dev/null +++ b/backend/script/sql/update/aihr_20260821_knowledge_shadow_gate_mysql8.sql @@ -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; diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 5c43a868..0b1b0d74 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -299,8 +299,43 @@ POST /api/knowledge/quality/assets/{assetId}/publish POST /api/knowledge/quality/assets/{assetId}/withdraw GET /api/knowledge/quality/legacy/preview?afterAttachmentId=0&limit=50 POST /api/knowledge/quality/legacy/stage?afterAttachmentId=0&limit=50 +GET /api/knowledge/quality/assets/{assetId}/versions +GET /api/knowledge/quality/assets/{assetId}/versions/{versionId} +GET /api/knowledge/quality/assets/{assetId}/versions/diff?fromVersionId=1&toVersionId=2 +GET /api/knowledge/quality/assets/{assetId}/review-package +POST /api/knowledge/quality/assets/review-batches +POST /api/knowledge/quality/assets/{assetId}/rollback +GET /api/knowledge/quality/assets/{assetId}/impact +POST /api/knowledge/quality/assets/{assetId}/purge-requests +GET /api/knowledge/quality/purge-requests +POST /api/knowledge/quality/purge-requests/{requestId}/decision +POST /api/knowledge/quality/purge-requests/{requestId}/execute +POST /api/knowledge/quality/reconciliation-runs +POST /api/knowledge/quality/migration-runs +GET /api/knowledge/quality/migration-runs +POST /api/knowledge/quality/migration-runs/{runId}/next +GET /api/knowledge/quality/migration-runs/{runId}/dead-letters +POST /api/knowledge/quality/migration-dead-letters/{deadLetterId}/retry +GET /api/knowledge/quality/rollout/scopes +POST /api/knowledge/quality/rollout/spaces/{knowledgeId}/transition +POST /api/knowledge/quality/rollout/spaces/{knowledgeId}/generation-builds +POST /api/knowledge/quality/rollout/spaces/{knowledgeId}/generation-builds/refresh +POST /api/knowledge/quality/rollout/spaces/{knowledgeId}/shadow-comparisons +POST /api/knowledge/quality/rollout/spaces/{knowledgeId}/activation-gates +POST /api/knowledge/quality/rollout/spaces/{knowledgeId}/activate +POST /api/knowledge/quality/rollout/spaces/{knowledgeId}/generation-rollback ``` +资料资产使用独立的 `published_version_id` 与 `candidate_version_id`。上传和重处理只推进候选指针,不能覆盖正在服务的生效版;发布在同一事务内切换生效指针、生产数据集成员和索引 outbox,旧版本转为 `SUPERSEDED` 并保留不可变片段。版本差异按正文 hash 与 chunk 序号生成可复算证据,回滚必须由登录审核人填写原因,且目标必须是曾经发布、仍有生产成员证据和不可变片段的历史版本。 + +审核包只提供风险、重复、冲突、质量问题和建议动作,不代替人工决定。低风险批量发布必须由人工显式发起、携带幂等 `batchKey`,逐项重新校验候选版本;`MEDIUM/HIGH/CRITICAL` 不进入批量发布。并发审核通过 `expectedVersionId` 拒绝已经换过候选版的陈旧操作。 + +撤回是可恢复的在线下线:立即停用生产成员并排队删除当前 generation 的向量,但保留版本和片段供审计/回滚。物理清理只能针对已撤回资产,先生成影响快照,再进入 `PENDING -> APPROVED -> EXECUTING -> EXECUTED/FAILED`;申请人与批准/执行人必须不同,`legal_hold`、`PERMANENT` 或未到 `execute_after` 一律拒绝。保留期为 `TEMPORARY=7`、`STANDARD=90`、`REGULATED=365` 天。独占 OSS、MySQL 版本/片段/证据和对应向量按审计请求清理,`aihr_deletion_request` 永久保留为墓碑;`reconciliation-runs` 用于发现 MySQL、OSS 元数据、向量计数和 chunk 向量血缘漂移。 + +历史补链使用迁移清单而不是无状态循环调用。每个 `migration-run` 冻结租户、可选知识空间、风险范围、dry-run、批大小和 manifest hash;`next` 只推进已持久化游标并记录批次证据。`LOW_RISK` 只扫描租户可验证 OSS 原件,`QUARANTINE_FIRST` 只扫描缺失原件且需要隔离的资料,`ALL` 扫描全部候选。失败附件进入显式 dead letter,最多按退避策略重试,不能因为单项失败回滚或隐藏仍由 LEGACY 服务的线上资料。正式任务必须提交 `verifiedDryRunId`,服务端只接受已完成且知识空间、风险范围、批大小完全一致的演练;管理端创建正式任务后仍需人工逐批推进,不会自动修改资料。 + +空间发布固定为 `LEGACY -> SHADOW -> ENFORCED`。SHADOW 仍服务旧链路,候选 generation 在独立 collection/generation 中全量构建;管理端可记录真实问题的双链路对照。启用门禁要求候选 generation 为 `READY`、至少 20 个样本、Top1 一致率不低于 90%、Top5 平均重合率不低于 80%、治理结果空集率不高于 1%,且无覆盖缺口、开放关键问题或索引死信。旧 transition 接口不能直接进入 ENFORCED;只有人工使用最新 `PASSED` gate 调用 `activate` 才能切换。原 generation 保留 7 天回退窗口,回退后恢复 SHADOW。 + `publish` 必须带人工审核意见、用途、来源权威级别和来源版本。请求中的 `acceptedReasonCodes` 只能确认当前版本仍开放的软提示,服务端会再次统计开放问题,遗漏任何一项都拒绝发布;`HARD` 问题不能在发布动作中接受,必须修订原资料并生成新版本,或通过后续独立的显式纠错流程解决。规范性知识仅接受 `FORMAL_POLICY / COMPANY_POLICY / REGULATION / APPROVED_SOP` 且版本非空;同名已发布资料内容变化会产生 `VERSION_CONFLICT`,规范性知识必须再传 `supersedesAssetId`,服务端验证同租户、同来源名和旧资产仍已发布后,在同一事务内撤回旧版本并发布新版本。 规范化采用版本化的 NFKC、换行、控制字符和空白处理;解析正文和规范化正文作为不可变审计证据保留,不作为下游生产内容。`privacy-redaction-v1` 从规范化正文生成独立的 `redacted_content/redacted_source_name`,记录派生 hash、分类计数、隐私状态和规则版本,并对派生正文及全部派生 chunk 复扫。只有 `CLEAN/REDACTED` 派生版本参与结构化切片、异步语义分析、模型调用、发布和检索;重复文件复用的历史摘要、模型新生成的摘要/标签/归类理由以及上传 snippet 也必须在返回或保存前走同一脱敏规则,不能因 OSS 对象复用而绕过。残留敏感模式写入 `BLOCKED / PII_DETECTED` 并拒绝发布,已完成替换写入 `PII_REDACTED` 证据。`GET /assets/{assetId}/privacy-preview` 只返回脱敏文件名、分类计数和截断后的脱敏正文,不返回原始正文。 diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index c789cd34..f32ed2d0 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -14,6 +14,8 @@ > > 2026-07-30 增量新增并发布五通道处理人配置:只有 `superadmin` 可查看当前租户处理人、检索合格候选并增删角色绑定;服务端排除 APP 用户、超级管理员、停用/删除和跨租户账号,写请求复核 `expectedTenantId`。生产五个角色均为 `2/2`,本通道正例、跨通道拒绝和一次正式回复已完成 `5/5`,2026-07-31 纳入受控内部公测签认。 > +> 2026-08-03 当前工作树完成数字资产全生命周期治理的本地实现与验证,尚未部署:先以按空间 `LEGACY/SHADOW/ENFORCED` 兼容层保护线上旧资料,再依次提供候选/生效双指针、不可变版本与审核辅助、generation 向量血缘、撤回与回滚、保留/法务冻结/异人清理、可续跑历史补链、影子对照和人工启用门禁。生产不得跳过兼容层直接执行七阶段,也不得在发布窗口执行物理清理或自动切换现有空间。 +> > 本文只维护当前结论、证据层级和剩余门槛。逐提交发布流水已从当前真相文档移除,需要追溯时使用 Git 历史;不得再用 2026-07-14/15 的“尚未发布”记录判断现网。 ## 1. 当前结论 @@ -26,10 +28,10 @@ | 层级 | 当前状态 | 证据与边界 | |---|---|---| -| 已实现 | 是 | 已有阶段一主功能;当前工作树另实现大喇叭投放控制、反馈转人工、正式学习任务/随机组卷、案例经验治理、原生异常恢复和上传容量门禁;接口事实见 [API_INTEGRATION.md](API_INTEGRATION.md) | -| 本地自动化/API 验证 | 是 | 当前工作树管理端生产构建、移动端类型检查和 `216/216` 单测通过;后端 Surefire 共 `107` 份报告、`838` 项测试,失败/错误/跳过均为 `0`;迁移静态清单通过,Docker/MySQL 幂等执行未完成 | -| 本地浏览器/视觉验证 | 部分 | `390×844` 已实际点击员工“今日/练/问”、大喇叭登录门禁和直通车登录门禁,无横向溢出或重叠;管理端认证后的案例发布弹窗因后端/MySQL 未运行而未验证。既有生产公司消息回归和 Android ASR 证据不外推到本批增量 | -| 已部署 | 部分 | 2026-07-25 至 2026-07-31 的既有生产基线已部署;当前工作树的四个 `aihr_20260802_*` 迁移及对应后端、管理端和移动端增量均未部署 | +| 已实现 | 是 | 已有阶段一主功能;当前工作树另实现大喇叭投放控制、反馈转人工、正式学习任务/随机组卷、案例经验治理、原生异常恢复、上传容量门禁,以及数字资产兼容放量、版本、审核、向量代次、撤回/回滚、保留清理、历史迁移和影子启用全链路;接口事实见 [API_INTEGRATION.md](API_INTEGRATION.md) | +| 本地自动化/API 验证 | 是 | 当前工作树管理端生产构建通过;后端完整测试 `851/851`,失败/错误/跳过均为 `0`;发布预检范围契约通过,生命周期迁移已在现有 MySQL 连续执行两次且资产数保持 `104`,未创建清理或迁移任务 | +| 本地浏览器/视觉验证 | 部分 | `390×844` 已实际点击员工“今日/练/问”、大喇叭登录门禁和直通车登录门禁,无横向溢出或重叠;2026-08-03 已在当前工作树管理端登录并实际打开空间放量、迁移/清理、版本审核辅助和影子启用对话框,桌面布局无明显重叠或截断,未提交任何状态变更。窄屏管理端本轮未复核 | +| 已部署 | 部分 | 2026-07-25 至 2026-07-31 的既有生产基线已部署;当前工作树的 `aihr_20260815_*` 至 `aihr_20260821_*` 生命周期迁移及对应后端、管理端均未部署,线上资料未发生本批变更 | | 生产基础验证 | 是 | 服务 `active`;根站、租户接口和移动首页 API 正常;媒体重试列/索引契约通过,4 个历史零片段任务均恢复为已完成且有片段/向量。当前未执行包含移动 H5 的完整包匹配 | | 生产完整业务验收 | 部分 | 公司消息已完成认证态全员文件消息和直通车闭环,单台 Android 标准基座已完成生产 ASR;训练写入、主管复盘、定向人群正反例、正式处理人绑定、其他真机媒体与严格试点窗口仍未完成 | @@ -43,6 +45,14 @@ - 运行门禁:资料暂存盘默认要求至少 10GiB 可用空间,生产 HTTP 请求启用 P50/P95/P99 指标;约 600 名用户不是 600 并发,压测模型和通过阈值仍待业务、实施和运维签认。 - 发布边界:四个正式迁移已纳入 Runbook;本地 Docker 未运行,未执行 MySQL 首次/重复迁移、完整后端认证 API、签名 APK、真机或生产验证。 +### 2026-08-03 数字资产生命周期本地实现边界 + +- 执行顺序已经重新编排,不是把自动化辅助作为七阶段之外的平行项目:阶段 0 兼容层必须先落地,随后七阶段能力按依赖进入同一发布计划;系统先生成清单、风险、差异、影响和建议,人工只负责业务判断、异人批准和最终启用。 +- 既有资料默认继续由 `LEGACY` 服务。历史补链必须先创建固定范围的 dry-run,只有无失败完成且知识空间、风险范围、批大小完全匹配的演练,才能生成正式任务;正式任务仍需人工逐批推进,单项失败进入 dead letter,不中断旧读链路。 +- 新上传或修订只推进候选版本,生效版本继续提供检索;发布、撤回、回滚和 generation 切换均保留审计证据。日常删除语义为可恢复撤回,物理清理另受保留期、法务冻结、引用影响和异人控制约束。 +- 生产启用固定为单空间 `LEGACY -> SHADOW -> ENFORCED`。SHADOW 期间构建隔离候选 generation 并记录真实问题对照;覆盖、索引、质量和样本门禁通过后,才允许人工确认启用,并保留七天 generation 回退窗口。 +- 本地浏览器仅执行只读查看和打开对话框。验收前后数据库均为资产 `104`、非活动删除状态 `0`、清理申请 `0`、迁移任务 `0`、影子样本 `0`、generation 启用 `0`;已有一个本地 SHADOW 空间保持不变。以上不能外推为生产部署或线上迁移完成。 + ### 2026-07-29 资料处理媒体恢复发布边界 历史图片/视频在视觉模型不接受原始分辨率/编码,或模型、ASR、关键帧链路没有返回可检索文字时,旧逻辑会留下状态 0、片段 0 的附件,页面长期显示“等待解析”,却没有从原件重新入队的入口。当前实现先对图片纠正方向、缩放和重编码;异步队列只有生成至少一个片段才进入完成态,零片段明确保持可重试失败。管理端可调用 `POST /api/knowledge/doc/processing-tasks/{attachmentId}/retry`,服务端按当前租户从原 OSS 对象重新暂存,通过附件状态 CAS 防止重复入队,并用 `source_attach_id` 关联新队列条目。 diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index 45e95947..30c79f30 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -166,6 +166,13 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260812_pipeline_run_sampling_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260813_golden_calibration_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260814_knowledge_privacy_derivative_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260815_knowledge_rollout_compat_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260816_knowledge_version_pointers_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260817_knowledge_review_assistance_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260818_knowledge_generation_revision_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260819_knowledge_retention_deletion_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260820_knowledge_migration_orchestration_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260821_knowledge_shadow_gate_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_personal_knowledge_mysql8.sql ``` @@ -173,6 +180,10 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_perso 随后按 `20260808 -> 20260809 -> 20260810 -> 20260811 -> 20260812 -> 20260813 -> 20260814` 执行质量监控、人工术语修订、AI 案例来源、规则演进、处理批次、黄金集校准和隐私派生迁移。`20260811` 只增加规则草稿、状态审计、影子对照和抽样复核;`20260812` 只增加 `pipeline_run` 与自动生成待人工复核样本的调度,不启用规则执法,不改变任何资产生命周期。`20260814` 只增加独立脱敏正文、脱敏文件名、派生 hash、隐私状态、规则版本和复扫摘要字段,历史版本默认保持 `NOT_PROCESSED`,不得通过 SQL 伪造脱敏完成或自动获批。完整迁移后仍须最后执行排序规则兼容迁移。 +生命周期兼容发布继续按 `20260815 -> 20260816 -> 20260817 -> 20260818 -> 20260819 -> 20260820 -> 20260821` 执行:先建立按空间的 `LEGACY/SHADOW/ENFORCED` 兼容范围,再增加候选/生效双指针、审核辅助、不可变 generation 与回滚证据、保留和异人删除、历史迁移清单、影子指标与启用门禁。七个脚本均为增量结构迁移;不得在 SQL 阶段自动发布、撤回、删除或切换现有空间。迁移完成后所有空间仍应为 LEGACY 或保持迁移前显式模式,历史资料继续可见。生产发布必须先只读统计资产、版本、生产成员、fragment、OSS 和 Qdrant 数量并备份;先以单空间 dry-run 和 SHADOW 验证,达到门禁后由人工启用。正式迁移任务必须通过 `verified_dry_run_id` 绑定同知识空间、同风险范围、同批大小且无失败的已完成演练;创建正式任务不自动推进批次。任何补链失败都停留在迁移 dead letter,不得切断旧读路径。 + +生产物理删除不是部署步骤,也不得与 schema/JAR 发布同窗口批量执行。日常纠错只做 `withdraw`;`purge` 必须有业务/法务确认的保留分类、影响快照、异人批准和已到期 `execute_after`。发布后先运行 reconciliation 并处理不一致,再考虑清理。ENFORCED 启用后旧 generation 保留七天;窗口内指标恶化可从管理端回退上一代,超过窗口必须重新构建候选 generation 和重新通过门禁。 + 8 月 1 日受控内部试点的正式来源注册是独立的业务数据变更,不随通用 schema 自动执行。先逐一核对附件 `id + doc_id`、原文件 SHA-256、发文号/版本、生效日期和适用范围,再由已授权负责人执行 `backend/script/sql/ops/aihr_20260730_aug1_formal_source_registry_mysql8.sql`。执行后必须只读确认恰好 10 条 `FORMAL_POLICY + APPROVED` 记录;任何缺失都保持无正式依据,不得把访谈、经验萃取、AI 生成内容或草稿补进白名单。 顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;五岗位题库之后的内容运营迁移为每个场景补齐唯一的初始 Rubric、五个稳定评分维度与训练时评分规则快照列,并只修正仍处于待训练状态的旧每日题场景编号,不重写已完成记录。紧随其后的成长目录迁移只在既有场景和会话表上增加岗位/层级/能力项/审核状态与会话快照,以及复盘会话专用的 `growth_confirmation_level/growth_confirmed_by/growth_confirmed_time`;它只允许记录主管对当前会话下一训练层级的确认,不改写原训练快照或人事结论,并补齐/修复派发请求的 `(tenant_id,request_key,ext_party_id)` 唯一键与最近内容过滤索引。它保留既有启停可用性和历史会话,不把迁移结果写成业务内容签字。生产环境不得设置 `AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP=true`:应用请求只读校验表、列和关键索引,任何缺失都必须先执行本节正式 SQL,不能由任一用户请求触发补列、补索引或状态回填。岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;正式知识来源治理迁移只创建附件级权威类型、生命周期、版本、生效期、适用范围和原文件哈希契约,不自动批准任何历史附件。移动端政策问答只使用有效的 `FORMAL_POLICY + APPROVED` 注册项,治理表缺失、来源未批准、已失效、版本或哈希为空时均按无正式依据关闭。银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。直通车迁移创建点对点反馈表,并给正式租户 `000000` 幂等预置总裁、财务、人力、审计、运营五个处理角色;人员仍须由管理员按职责分配,业务匿名不抹除内部审计身份。公司文件迁移创建异步提炼表并给消息增加可空 `attachment_id`,不迁移或公开已有文件;紧随其后的多岗位解读迁移只在附件表增加生成状态、经引用校验的结构化结果和规则版本,并建立异步队列索引,不回填旧文件、不改变文件 READY 或消息发布状态。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。Agent 迁移只新增最小运行审计表,不保存问题、答案、密钥或模型推理。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。 diff --git a/frontend/src/api/aihr/processing.ts b/frontend/src/api/aihr/processing.ts index 08d4e5da..e951a3e3 100644 --- a/frontend/src/api/aihr/processing.ts +++ b/frontend/src/api/aihr/processing.ts @@ -187,6 +187,129 @@ export type LegacyStagePreview = { moreAvailable: boolean; }; +export type KnowledgeRolloutMode = 'LEGACY' | 'SHADOW' | 'ENFORCED'; + +export type KnowledgeRolloutScope = { + knowledgeId: number; + spaceCode: string; + spaceName: string; + mode: KnowledgeRolloutMode; + governedCollection: string; + activeGeneration: number; + candidateGeneration?: number; + updatedBy?: number; + reason?: string; + updatedTime?: string; + readyAttachments: number; + enforcementBlockers: number; +}; + +export type KnowledgeVersion = { + id: number; + versionNo: number; + versionStatus: string; + revisionOfVersionId?: number; + revisionReason?: string; + contentSha256: string; + parserName?: string; + parserVersion?: string; + semanticAnalysisStatus: string; + privacyStatus: string; + published: boolean; + candidate: boolean; + chunkCount: number; + openIssueCount: number; + createTime: string; +}; + +export type ReviewPackage = { + assetId: number; + versionId: number; + versionNo: number; + riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; + batchEligible: boolean; + summary: { + sourceName: string; + contentPreview: string; + headings: string[]; + chunkCount: number; + issueCount: number; + duplicateCount: number; + conflictCount: number; + }; + suggestion: { decision: string; reasonCodes: string[]; recommendedActions: string[]; disclaimer: string }; +}; + +export type DeletionImpact = { + assetId: number; + lifecycleStatus: string; + retentionClass: string; + legalHold: boolean; + chunks: number; + publishedFragments: number; + queryEvidence: number; + learningTasks: number; + broadcasts: number; + generations: number; + canRequestPurge: boolean; + blockers: string[]; +}; + +export type PurgeRequest = { + id: number; + requestKey: string; + assetId: number; + retentionClass: string; + reason: string; + status: string; + requesterId: number; + requestedTime: string; + approverId?: number; + approvalReason?: string; + executeAfter: string; + executedTime?: string; + lastError?: string; +}; + +export type MigrationRun = { + id: number; + runKey: string; + knowledgeId?: number; + riskScope: string; + dryRun: boolean; + batchSize: number; + cursorAttachmentId: number; + status: string; + discoveredCount: number; + stagedCount: number; + quarantinedCount: number; + failedCount: number; + manifestSha256: string; + verifiedDryRunId?: number; +}; + +export type GenerationBuild = { + knowledgeId: number; + generation: number; + collection: string; + status: string; + expectedVersions: number; + includedVersions: number; + outstandingJobs: number; +}; + +export type ActivationGate = { + gateId: number; + knowledgeId: number; + candidateGeneration: number; + status: 'PASSED' | 'BLOCKED'; + sampleCount: number; + top1AgreementRate: number; + overlapAt5Avg: number; + governedEmptyRate: number; + evidence: { blockingAssets: number; openCriticalIssues: number; deadLetters: number; generationStatus: string }; +}; + export type KnowledgeGlossaryTerm = { id: number; term: string; @@ -601,6 +724,111 @@ export function previewLegacyQualityAssets(limit = 50, afterAttachmentId = 0): P }); } +export function listKnowledgeRolloutScopes(): Promise> { + return request({ + url: '/api/knowledge/quality/rollout/scopes', + method: 'get' + }); +} + +export function transitionKnowledgeRollout( + knowledgeId: number, + targetMode: KnowledgeRolloutMode, + reason: string, + activeGeneration?: number +): Promise> { + 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> { + return request({ url: `/api/knowledge/quality/assets/${assetId}/versions`, method: 'get' }); +} + +export function getAssetReviewPackage(assetId: number): Promise> { + return request({ url: `/api/knowledge/quality/assets/${assetId}/review-package`, method: 'get' }); +} + +export function getDeletionImpact(assetId: number): Promise> { + return request({ url: `/api/knowledge/quality/assets/${assetId}/impact`, method: 'get' }); +} + +export function createPurgeRequest(assetId: number, reason: string): Promise> { + 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> { + return request({ url: '/api/knowledge/quality/purge-requests', method: 'get', params: { status, limit } }); +} + +export function decidePurgeRequest(id: number, decision: 'APPROVE' | 'REJECT', reason: string): Promise> { + return request({ url: `/api/knowledge/quality/purge-requests/${id}/decision`, method: 'post', data: { decision, reason } }); +} + +export function executePurgeRequest(id: number): Promise> { + return request({ url: `/api/knowledge/quality/purge-requests/${id}/execute`, method: 'post' }); +} + +export function runKnowledgeReconciliation(): Promise> { + return request({ url: '/api/knowledge/quality/reconciliation-runs', method: 'post' }); +} + +export function listMigrationRuns(limit = 50): Promise> { + 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> { + return request({ url: '/api/knowledge/quality/migration-runs', method: 'post', data, headers: { repeatSubmit: false } }); +} + +export function runNextMigrationBatch(id: number): Promise> { + return request({ url: `/api/knowledge/quality/migration-runs/${id}/next`, method: 'post', headers: { repeatSubmit: false } }); +} + +export function startGenerationBuild(knowledgeId: number, reason: string): Promise> { + return request({ url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/generation-builds`, method: 'post', data: { reason } }); +} + +export function refreshGenerationBuild(knowledgeId: number): Promise> { + return request({ url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/generation-builds/refresh`, method: 'post' }); +} + +export function compareShadowQuery(knowledgeId: number, queryText: string): Promise> { + return request({ + url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/shadow-comparisons`, + method: 'post', + data: { queryText, source: 'ADMIN_WORKBENCH' } + }); +} + +export function evaluateActivationGate(knowledgeId: number): Promise> { + return request({ url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/activation-gates`, method: 'post' }); +} + +export function activateGeneration(knowledgeId: number, gateId: number, reason: string): Promise> { + return request({ url: `/api/knowledge/quality/rollout/spaces/${knowledgeId}/activate`, method: 'post', data: { gateId, reason } }); +} + +export function rollbackGeneration(knowledgeId: number, reason: string): Promise> { + 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> { return request({ url: '/api/knowledge/quality/glossary', diff --git a/frontend/src/views/knowledge/processing.vue b/frontend/src/views/knowledge/processing.vue index e6ff1587..c57b7bae 100644 --- a/frontend/src/views/knowledge/processing.vue +++ b/frontend/src/views/knowledge/processing.vue @@ -256,6 +256,57 @@ 刷新 +
+
+
+ 空间放量 + 旧链路、影子验证和治理链路按空间独立切换 +
+ +
+ + + + + + + + + + + + + + + + +
{{ indexHealth.healthy ? '索引一致' : '索引需处理' }} @@ -340,6 +391,74 @@ + +
+
+
+ 迁移与清理任务 + 历史补链默认先演练;物理清理必须经过异人审批和保留期。 +
+
+ 新建迁移演练 + 运行存储对账 + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@@ -781,6 +900,51 @@ {{ selectedQualityAsset.semanticRetryCount }} +
+
+

版本与审核辅助

+ + {{ reviewPackage.riskLevel }} 风险 + +
+
+ 候选 v{{ reviewPackage.versionNo }} + {{ reviewPackage.summary.chunkCount }} 个片段 + {{ reviewPackage.summary.duplicateCount }} 组重复 + {{ reviewPackage.summary.conflictCount }} 项冲突 +
+ + + + + + + + + + + 申请物理清理 +
+ + + + + + (null); const indexHealthFailed = ref(false); const qualityMetrics = ref(null); const qualityAlerts = ref([]); +const rolloutLoading = ref(false); +const rolloutTransitioningId = ref(null); +const rolloutScopes = ref([]); +const rolloutDialogVisible = ref(false); +const selectedRolloutScope = ref(null); +const rolloutGate = ref(null); +const rolloutQuery = ref(''); +const rolloutAssisting = ref(false); +const governanceLoading = ref(false); +const migrationRuns = ref([]); +const purgeRequests = ref([]); +const assetVersions = ref([]); +const reviewPackage = ref(null); +const deletionImpact = ref(null); const glossaryLoading = ref(false); const glossarySaving = ref(false); const glossaryStatus = ref('ACTIVE'); @@ -1424,6 +1667,8 @@ onMounted(async () => { loadQualityAssets(), loadIndexHealth(), loadQualityMonitoring(), + loadRolloutScopes(), + loadGovernanceOperations(), loadGlossary(), loadRuleWorkbench(), loadGoldenDatasets() @@ -1462,6 +1707,284 @@ async function loadQualityAssets() { } } +async function loadRolloutScopes() { + rolloutLoading.value = true; + try { + rolloutScopes.value = (await listKnowledgeRolloutScopes()).data || []; + } catch { + rolloutScopes.value = []; + } finally { + rolloutLoading.value = false; + } +} + +async function transitionRollout(scope: KnowledgeRolloutScope, targetMode: KnowledgeRolloutMode) { + const targetLabel = rolloutModeLabel(targetMode); + try { + const { value } = await ElMessageBox.prompt( + targetMode === 'ENFORCED' + ? '启用后该空间只召回已审核发布且索引就绪的治理版本。' + : targetMode === 'LEGACY' + ? '回退后该空间立即继续使用原有资料与旧向量。' + : '影子模式继续服务旧结果,同时允许生成治理链路对照数据。', + `切换到${targetLabel}`, + { + confirmButtonText: '确认切换', + cancelButtonText: '取消', + inputType: 'textarea', + inputPlaceholder: '填写切换原因', + inputValidator: (input) => Boolean(input?.trim()) || '请填写切换原因' + } + ); + rolloutTransitioningId.value = scope.knowledgeId; + await transitionKnowledgeRollout(scope.knowledgeId, targetMode, value.trim(), scope.activeGeneration); + await loadRolloutScopes(); + ElMessage.success(`${scope.spaceName}已切换到${targetLabel}`); + } catch (error) { + if (error !== 'cancel' && error !== 'close') ElMessage.error('空间模式切换失败,请检查覆盖率与索引状态'); + } finally { + rolloutTransitioningId.value = null; + } +} + +async function loadGovernanceOperations() { + governanceLoading.value = true; + try { + const [migrations, purges] = await Promise.all([listMigrationRuns(), listPurgeRequests()]); + migrationRuns.value = migrations.data || []; + purgeRequests.value = purges.data || []; + } catch { + migrationRuns.value = []; + purgeRequests.value = []; + } finally { + governanceLoading.value = false; + } +} + +async function createMigrationPlan() { + try { + const { value } = await ElMessageBox.prompt('可选:输入知识空间 ID;留空则覆盖当前租户全部空间。', '新建历史迁移演练', { + confirmButtonText: '创建演练', + cancelButtonText: '取消', + inputPlaceholder: '知识空间 ID(可留空)', + inputValidator: (input) => !input?.trim() || /^\d+$/.test(input.trim()) || '请输入数字 ID' + }); + const knowledgeId = value?.trim() ? Number(value.trim()) : undefined; + await createMigrationRun({ + runKey: `admin-dry-run:${knowledgeId || 'all'}:${Date.now()}`, + knowledgeId, + riskScope: 'ALL', + dryRun: true, + batchSize: 50 + }); + await loadGovernanceOperations(); + ElMessage.success('迁移演练清单已创建,尚未修改任何资料'); + } catch (error) { + if (error !== 'cancel' && error !== 'close') ElMessage.error('迁移演练创建失败'); + } +} + +async function advanceMigration(run: MigrationRun) { + governanceLoading.value = true; + try { + const result = (await runNextMigrationBatch(run.id)).data; + await loadGovernanceOperations(); + ElMessage.success(result.moreAvailable ? '本批完成,可继续下一批' : '迁移任务已完成'); + } catch { + ElMessage.error('迁移批次执行失败,任务游标未丢失'); + } finally { + governanceLoading.value = false; + } +} + +async function createFormalMigration(dryRun: MigrationRun) { + try { + await ElMessageBox.confirm(`将依据演练 #${dryRun.id} 生成同范围正式迁移任务。创建后不会立即改动资料,仍需人工逐批执行。`, '确认生成正式任务', { + confirmButtonText: '生成正式任务', + cancelButtonText: '取消', + type: 'warning' + }); + await createMigrationRun({ + runKey: `admin-formal:${dryRun.knowledgeId || 'all'}:${Date.now()}`, + knowledgeId: dryRun.knowledgeId, + riskScope: dryRun.riskScope, + dryRun: false, + batchSize: dryRun.batchSize, + verifiedDryRunId: dryRun.id + }); + await loadGovernanceOperations(); + ElMessage.success('正式迁移任务已创建,尚未执行任何批次'); + } catch (error) { + if (error !== 'cancel' && error !== 'close') ElMessage.error('正式迁移任务创建失败'); + } +} + +async function reconcileKnowledgeStores() { + governanceLoading.value = true; + try { + const result = (await runKnowledgeReconciliation()).data; + ElMessage[result.status === 'PASSED' ? 'success' : 'warning']( + result.status === 'PASSED' ? 'MySQL、OSS 与向量血缘对账通过' : `对账发现 ${result.issues.length} 项待处理问题` + ); + } catch { + ElMessage.error('存储对账执行失败'); + } finally { + governanceLoading.value = false; + } +} + +async function reviewPurge(request: PurgeRequest, decision: 'APPROVE' | 'REJECT') { + try { + const { value } = await ElMessageBox.prompt( + decision === 'APPROVE' ? '批准人必须与申请人不同;批准后仍需等待保留期。' : '请说明驳回依据。', + decision === 'APPROVE' ? '批准清理申请' : '驳回清理申请', + { + confirmButtonText: decision === 'APPROVE' ? '确认批准' : '确认驳回', + cancelButtonText: '取消', + inputType: 'textarea', + inputValidator: (input) => Boolean(input?.trim()) || '请填写审核依据' + } + ); + await decidePurgeRequest(request.id, decision, value.trim()); + await loadGovernanceOperations(); + ElMessage.success(decision === 'APPROVE' ? '清理申请已批准' : '清理申请已驳回'); + } catch (error) { + if (error !== 'cancel' && error !== 'close') ElMessage.error('清理申请审核失败'); + } +} + +async function executePurge(request: PurgeRequest) { + try { + await ElMessageBox.confirm( + `将不可恢复地清理资产 #${request.assetId} 的 MySQL、向量与独占 OSS 数据。系统仍会校验保留期和异人控制。`, + '执行物理清理', + { confirmButtonText: '确认执行', cancelButtonText: '取消', type: 'error' } + ); + await executePurgeRequest(request.id); + await Promise.all([loadGovernanceOperations(), loadQualityAssets(), loadIndexHealth()]); + ElMessage.success('物理清理已完成并保留审计墓碑'); + } catch (error) { + if (error !== 'cancel' && error !== 'close') ElMessage.error('物理清理未执行,请检查保留期、审批人和存储状态'); + } +} + +function openRolloutAssistant(scope: KnowledgeRolloutScope) { + selectedRolloutScope.value = scope; + rolloutGate.value = null; + rolloutQuery.value = ''; + rolloutDialogVisible.value = true; +} + +async function buildCandidateGeneration() { + const scope = selectedRolloutScope.value; + if (!scope) return; + rolloutAssisting.value = true; + try { + await startGenerationBuild(scope.knowledgeId, '管理端启动候选代次全量构建'); + await loadRolloutScopes(); + selectedRolloutScope.value = rolloutScopes.value.find((item) => item.knowledgeId === scope.knowledgeId) || scope; + ElMessage.success('候选代次已排队构建'); + } catch { + ElMessage.error('候选代次构建启动失败'); + } finally { + rolloutAssisting.value = false; + } +} + +async function refreshCandidateGeneration() { + const scope = selectedRolloutScope.value; + if (!scope) return; + rolloutAssisting.value = true; + try { + const result = (await refreshGenerationBuild(scope.knowledgeId)).data; + ElMessage.info(`候选 G${result.generation}:${result.status},已纳入 ${result.includedVersions}/${result.expectedVersions}`); + } catch { + ElMessage.error('候选代次状态刷新失败'); + } finally { + rolloutAssisting.value = false; + } +} + +async function runShadowComparison() { + const scope = selectedRolloutScope.value; + if (!scope || !rolloutQuery.value.trim()) return; + rolloutAssisting.value = true; + try { + await compareShadowQuery(scope.knowledgeId, rolloutQuery.value.trim()); + rolloutQuery.value = ''; + ElMessage.success('影子对照样本已记录,不影响当前在线结果'); + } catch { + ElMessage.error('影子对照失败,请确认候选代次已创建'); + } finally { + rolloutAssisting.value = false; + } +} + +async function checkActivationGate() { + const scope = selectedRolloutScope.value; + if (!scope) return; + rolloutAssisting.value = true; + try { + rolloutGate.value = (await evaluateActivationGate(scope.knowledgeId)).data; + } catch { + ElMessage.error('启用门禁评估失败'); + } finally { + rolloutAssisting.value = false; + } +} + +async function activateCandidateGeneration() { + const scope = selectedRolloutScope.value; + const gate = rolloutGate.value; + if (!scope || !gate || gate.status !== 'PASSED') return; + try { + const { value } = await ElMessageBox.prompt('启用后员工检索将切换到候选代次,七天内可回退。', '确认启用治理链路', { + confirmButtonText: '确认启用', + cancelButtonText: '取消', + inputType: 'textarea', + inputPlaceholder: '填写启用原因', + inputValidator: (input) => Boolean(input?.trim()) || '请填写启用原因' + }); + rolloutAssisting.value = true; + await activateGeneration(scope.knowledgeId, gate.gateId, value.trim()); + rolloutDialogVisible.value = false; + await loadRolloutScopes(); + ElMessage.success('候选代次已启用,回退窗口为七天'); + } catch (error) { + if (error !== 'cancel' && error !== 'close') ElMessage.error('候选代次启用失败'); + } finally { + rolloutAssisting.value = false; + } +} + +async function rollbackActiveGeneration(scope: KnowledgeRolloutScope) { + try { + const { value } = await ElMessageBox.prompt('仅能在最近一次启用后的七天窗口内回退。', '回退向量代次', { + confirmButtonText: '确认回退', + cancelButtonText: '取消', + inputType: 'textarea', + inputPlaceholder: '填写回退原因', + inputValidator: (input) => Boolean(input?.trim()) || '请填写回退原因' + }); + rolloutTransitioningId.value = scope.knowledgeId; + await rollbackGeneration(scope.knowledgeId, value.trim()); + await loadRolloutScopes(); + ElMessage.success('已回退上一代并返回影子模式'); + } catch (error) { + if (error !== 'cancel' && error !== 'close') ElMessage.error('代次回退失败或已超过回退窗口'); + } finally { + rolloutTransitioningId.value = null; + } +} + +function rolloutModeLabel(mode: KnowledgeRolloutMode) { + return { LEGACY: '旧链路', SHADOW: '影子', ENFORCED: '治理链路' }[mode]; +} + +function rolloutModeType(mode: KnowledgeRolloutMode) { + return ({ LEGACY: 'info', SHADOW: 'warning', ENFORCED: 'success' } as const)[mode]; +} + async function loadGlossary() { glossaryLoading.value = true; try { @@ -2059,6 +2582,9 @@ async function openQualityReview(asset: QualityAsset) { qualityPrivacyPreview.value = null; qualityConflicts.value = []; publishedQualityAssets.value = []; + assetVersions.value = []; + reviewPackage.value = null; + deletionImpact.value = null; qualityDetailLoading.value = true; qualityDialogVisible.value = true; qualityReviewForm.value = { @@ -2075,23 +2601,32 @@ async function openQualityReview(asset: QualityAsset) { supersedesAssetId: undefined }; try { - const [issues, privacyPreview, conflicts, published] = await Promise.all([ + const [issues, privacyPreview, conflicts, published, versions, impact, review] = await Promise.all([ listQualityIssues(asset.id), getQualityPrivacyPreview(asset.id), listQualityConflicts(asset.id), - listQualityAssets('PUBLISHED', 200) + listQualityAssets('PUBLISHED', 200), + listAssetVersions(asset.id), + getDeletionImpact(asset.id), + asset.lifecycleStatus === 'REVIEW_PENDING' ? getAssetReviewPackage(asset.id) : Promise.resolve({ data: null }) ]); if (selectedQualityAsset.value?.id !== asset.id) return; qualityIssues.value = issues.data || []; qualityPrivacyPreview.value = privacyPreview.data || null; qualityConflicts.value = conflicts.data || []; publishedQualityAssets.value = (published.data || []).filter((item) => item.id !== asset.id); + assetVersions.value = versions.data || []; + deletionImpact.value = impact.data || null; + reviewPackage.value = review.data || null; } catch { if (selectedQualityAsset.value?.id !== asset.id) return; qualityIssues.value = []; qualityPrivacyPreview.value = null; qualityConflicts.value = []; publishedQualityAssets.value = []; + assetVersions.value = []; + reviewPackage.value = null; + deletionImpact.value = null; ElMessage.error('质量原因加载失败'); } finally { if (selectedQualityAsset.value?.id === asset.id) qualityDetailLoading.value = false; @@ -2578,6 +3113,23 @@ function fileBadgeClass(type: string) { if (type === '视频') return 'ppt'; return 'text'; } +async function requestAssetPurge(asset: QualityAsset) { + try { + const { value } = await ElMessageBox.prompt('申请不会立即删除。系统将在保留期结束后,要求另一名管理员审批并执行。', '申请物理清理', { + confirmButtonText: '提交申请', + cancelButtonText: '取消', + inputType: 'textarea', + inputPlaceholder: '填写必须物理清理的原因', + inputValidator: (input) => Boolean(input?.trim()) || '请填写清理原因' + }); + await createPurgeRequest(asset.id, value.trim()); + qualityDialogVisible.value = false; + await loadGovernanceOperations(); + ElMessage.success('清理申请已提交,等待异人审批和保留期'); + } catch (error) { + if (error !== 'cancel' && error !== 'close') ElMessage.error('清理申请提交失败'); + } +}