feat: govern knowledge assets and source citations

This commit is contained in:
key
2026-08-02 01:43:43 +08:00
parent cafb836cda
commit 699cc08050
144 changed files with 17205 additions and 453 deletions
@@ -0,0 +1,22 @@
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
const apply = process.argv.includes('--apply');
const tenant = process.env.AIHR_TENANT_ID || '000000';
const sql = `insert ignore into aihr_knowledge_fragment_locator
(tenant_id, fragment_id, knowledge_id, doc_id, attachment_id, source_kind, paragraph_start, paragraph_end, locator_version, create_time, update_time)
select f.tenant_id,f.id,f.knowledge_id,f.doc_id,a.id,
case when lower(a.name) regexp '\\\\.(pdf)$' then 'PDF' when lower(a.name) regexp '\\\\.(doc|docx)$' then 'DOCX'
when lower(a.name) regexp '\\\\.(ppt|pptx)$' then 'PPTX' when lower(a.name) regexp '\\\\.(xls|xlsx)$' then 'XLSX'
when lower(a.name) regexp '\\\\.(png|jpg|jpeg|gif|webp|bmp)$' then 'IMAGE' when lower(a.name) regexp '\\\\.(mp4|mov|avi|mkv|webm|m4v)$' then 'VIDEO'
else 'TEXT' end,f.idx,f.idx,'backfill-v1',now(),now()
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
left join aihr_knowledge_fragment_locator l on l.tenant_id=f.tenant_id and l.fragment_id=f.id
where f.tenant_id='${tenant.replaceAll("'", "")}' and l.id is null order by f.id limit 1000;`;
const hash = createHash('sha256').update(sql).digest('hex');
if (!apply) {
console.log(JSON.stringify({ mode: 'plan', tenant, authorization: `MIGRATE_DB:${hash}`, writes: false }, null, 2));
process.exit(0);
}
if (process.env.AIHR_MIGRATE_AUTH !== `MIGRATE_DB:${hash}`) throw new Error('missing exact MIGRATE_DB authorization');
execFileSync('docker', ['exec', '-i', 'wygj-mysql', 'mysql', '-uroot', '-proot', '--default-character-set=utf8mb4', 'ry-vue', '-e', sql], { stdio: 'inherit' });
+1 -1
View File
@@ -40,7 +40,7 @@ elif find ruoyi-admin ruoyi-common ruoyi-modules -type f \( -name '*.java' -o -n
fi
if [[ "$NEEDS_BUILD" == true ]]; then
mvn -pl ruoyi-admin -am -DskipTests package
mvn -pl ruoyi-admin -am -DskipTests clean package
fi
exec java -jar "$JAR" \
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
function hitId(hit) {
return String(typeof hit === 'object' && hit !== null ? hit.fragmentId : hit);
}
function hitSource(hit) {
return typeof hit === 'object' && hit !== null ? String(hit.sourceName || hit.title || '') : '';
}
export function reciprocalRank(retrieved, relevant) {
const relevantSet = new Set(relevant.map(String));
const rank = retrieved.findIndex((hit) => relevantSet.has(hitId(hit)));
return rank < 0 ? 0 : 1 / (rank + 1);
}
export function recallAtK(retrieved, relevant) {
const relevantSet = new Set(relevant.map(String));
if (relevantSet.size === 0) return 0;
const hits = retrieved.filter((hit) => relevantSet.has(hitId(hit)));
return new Set(hits.map(hitId)).size / relevantSet.size;
}
export function ndcgAtK(retrieved, relevant) {
const relevantSet = new Set(relevant.map(String));
if (relevantSet.size === 0) return 0;
const dcg = retrieved.reduce((sum, hit, index) =>
sum + (relevantSet.has(hitId(hit)) ? 1 / Math.log2(index + 2) : 0), 0);
const idealLength = Math.min(relevantSet.size, retrieved.length);
const ideal = Array.from({ length: idealLength }, (_, index) => 1 / Math.log2(index + 2))
.reduce((sum, value) => sum + value, 0);
return ideal === 0 ? 0 : dcg / ideal;
}
export function evaluateCase(retrieved, testCase, k) {
const ranked = retrieved.slice(0, k);
const relevant = testCase.relevantFragmentIds || [];
const forbiddenIds = new Set((testCase.forbiddenFragmentIds || []).map(String));
const forbiddenSources = new Set((testCase.forbiddenSourceNames || []).map((value) => value.toLowerCase()));
const requiredSources = new Set((testCase.requiredSourceNames || []).map((value) => value.toLowerCase()));
const returnedSources = new Set(ranked.map(hitSource).filter(Boolean).map((value) => value.toLowerCase()));
const forbiddenLeak = ranked.some((hit) => forbiddenIds.has(hitId(hit))
|| forbiddenSources.has(hitSource(hit).toLowerCase()));
const missingRequiredSource = requiredSources.size > 0
&& ![...requiredSources].some((source) => returnedSources.has(source));
const expectedNoEvidence = Boolean(testCase.expectedNoEvidence);
return {
id: testCase.id,
caseType: testCase.caseType || (expectedNoEvidence ? 'no-answer' : 'answerable'),
answerable: !expectedNoEvidence,
recallAtK: expectedNoEvidence ? null : recallAtK(ranked, relevant),
reciprocalRank: expectedNoEvidence ? null : reciprocalRank(ranked, relevant),
ndcgAtK: expectedNoEvidence ? null : ndcgAtK(ranked, relevant),
forbiddenLeak,
wrongSource: forbiddenLeak || missingRequiredSource,
returned: ranked.length,
noEvidence: ranked.length === 0,
noAnswerFalsePositive: expectedNoEvidence && ranked.length > 0
};
}
function rate(rows, predicate) {
return rows.length ? rows.filter(predicate).length / rows.length : 0;
}
function average(rows, key) {
return rows.length ? rows.reduce((sum, row) => sum + row[key], 0) / rows.length : 0;
}
export function summarize(results) {
const answerable = results.filter((row) => row.answerable);
const noAnswer = results.filter((row) => !row.answerable);
const byCaseType = Object.fromEntries([...new Set(results.map((row) => row.caseType))].sort().map((caseType) => {
const rows = results.filter((row) => row.caseType === caseType);
return [caseType, { cases: rows.length, forbiddenLeakageRate: rate(rows, (row) => row.forbiddenLeak) }];
}));
return {
cases: results.length,
answerableCases: answerable.length,
noAnswerCases: noAnswer.length,
recallAtK: average(answerable, 'recallAtK'),
mrr: average(answerable, 'reciprocalRank'),
ndcgAtK: average(answerable, 'ndcgAtK'),
forbiddenLeakageRate: rate(results, (row) => row.forbiddenLeak),
wrongSourceRate: rate(results, (row) => row.wrongSource),
noAnswerFalsePositiveRate: rate(noAnswer, (row) => row.noAnswerFalsePositive),
noAnswerPrecision: 1 - rate(noAnswer, (row) => row.noAnswerFalsePositive),
byCaseType
};
}
export function thresholdFailures(summary, thresholds) {
const failures = [];
if (thresholds.minRecall !== undefined && summary.recallAtK < thresholds.minRecall) {
failures.push(`recallAtK ${summary.recallAtK} < ${thresholds.minRecall}`);
}
if (thresholds.minNdcg !== undefined && summary.ndcgAtK < thresholds.minNdcg) {
failures.push(`ndcgAtK ${summary.ndcgAtK} < ${thresholds.minNdcg}`);
}
if (thresholds.minMrr !== undefined && summary.mrr < thresholds.minMrr) {
failures.push(`mrr ${summary.mrr} < ${thresholds.minMrr}`);
}
if (thresholds.maxLeakage !== undefined && summary.forbiddenLeakageRate > thresholds.maxLeakage) {
failures.push(`forbiddenLeakageRate ${summary.forbiddenLeakageRate} > ${thresholds.maxLeakage}`);
}
if (thresholds.maxNoAnswerFalsePositive !== undefined
&& summary.noAnswerFalsePositiveRate > thresholds.maxNoAnswerFalsePositive) {
failures.push(`noAnswerFalsePositiveRate ${summary.noAnswerFalsePositiveRate} > ${thresholds.maxNoAnswerFalsePositive}`);
}
if (thresholds.maxWrongSource !== undefined && summary.wrongSourceRate > thresholds.maxWrongSource) {
failures.push(`wrongSourceRate ${summary.wrongSourceRate} > ${thresholds.maxWrongSource}`);
}
return failures;
}
export function parseArgs(argv) {
const options = { baseUrl: process.env.AIHR_EVAL_BASE_URL || 'http://127.0.0.1:8080', k: 5 };
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (value === '--dataset') options.dataset = argv[++index];
else if (value === '--base-url') options.baseUrl = argv[++index];
else if (value === '--token') options.token = argv[++index];
else if (value === '--clientid') options.clientid = argv[++index];
else if (value === '--k') options.k = Number(argv[++index]);
else if (value === '--dry-run') options.dryRun = true;
else if (value === '--min-recall') options.minRecall = Number(argv[++index]);
else if (value === '--min-ndcg') options.minNdcg = Number(argv[++index]);
else if (value === '--min-mrr') options.minMrr = Number(argv[++index]);
else if (value === '--max-leakage') options.maxLeakage = Number(argv[++index]);
else if (value === '--max-wrong-source') options.maxWrongSource = Number(argv[++index]);
else if (value === '--max-no-answer-false-positive') options.maxNoAnswerFalsePositive = Number(argv[++index]);
else throw new Error(`Unknown option: ${value}`);
}
if (!options.dataset) throw new Error('--dataset is required');
if (!Number.isInteger(options.k) || options.k < 1 || options.k > 50) {
throw new Error('--k must be an integer between 1 and 50');
}
for (const key of ['minRecall', 'minNdcg', 'minMrr', 'maxLeakage', 'maxWrongSource', 'maxNoAnswerFalsePositive']) {
if (options[key] !== undefined && (!Number.isFinite(options[key]) || options[key] < 0 || options[key] > 1)) {
throw new Error(`--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)} must be between 0 and 1`);
}
}
return options;
}
export function validateDataset(dataset) {
if (dataset?.schemaVersion !== 1) throw new Error('dataset.schemaVersion must be 1');
if (!dataset || !Array.isArray(dataset.cases) || dataset.cases.length === 0) {
throw new Error('dataset.cases must be a non-empty array');
}
const ids = new Set();
for (const item of dataset.cases) {
if (!item.id || ids.has(item.id) || !item.query) throw new Error(`invalid or duplicate case: ${item.id || '<missing>'}`);
ids.add(item.id);
if (!item.expectedNoEvidence && (!Array.isArray(item.relevantFragmentIds) || item.relevantFragmentIds.length === 0)) {
throw new Error(`answerable case must define relevantFragmentIds: ${item.id}`);
}
if (item.expectedNoEvidence && (item.relevantFragmentIds || []).length > 0) {
throw new Error(`no-answer case cannot define relevantFragmentIds: ${item.id}`);
}
}
}
async function requestSearch(options, testCase, fetchImpl) {
const headers = { 'content-type': 'application/json' };
if (options.token) headers.Authorization = options.token.startsWith('Bearer ') ? options.token : `Bearer ${options.token}`;
if (options.clientid) headers.clientid = options.clientid;
const response = await fetchImpl(`${options.baseUrl.replace(/\/$/, '')}/api/knowledge/search`, {
method: 'POST', headers,
body: JSON.stringify({
queryText: testCase.query,
category: testCase.category || undefined,
position: testCase.position || undefined,
source: 'knowledge_search',
limit: options.k
})
});
const body = await response.json();
if (!response.ok || body.code !== 200) {
throw new Error(`case ${testCase.id} search failed with HTTP ${response.status} code ${body.code}`);
}
const snippets = body.data?.snippets || [];
return snippets.filter((snippet) => snippet.fragmentId !== null && snippet.fragmentId !== undefined)
.map((snippet) => ({ fragmentId: snippet.fragmentId, sourceName: snippet.title || '' }));
}
export async function run(options, dependencies = {}) {
const dataset = JSON.parse(await fs.readFile(options.dataset, 'utf8'));
validateDataset(dataset);
if (options.dryRun) {
return { datasetCode: dataset.datasetCode, schemaVersion: dataset.schemaVersion,
cases: dataset.cases.length, k: options.k, dryRun: true };
}
const fetchImpl = dependencies.fetchImpl || globalThis.fetch;
const results = [];
for (const testCase of dataset.cases) {
const retrieved = await requestSearch(options, testCase, fetchImpl);
results.push(evaluateCase(retrieved, testCase, options.k));
}
const summary = summarize(results);
const failures = thresholdFailures(summary, options);
return { datasetCode: dataset.datasetCode, schemaVersion: dataset.schemaVersion, k: options.k,
summary, thresholdPassed: failures.length === 0, thresholdFailures: failures, cases: results };
}
if (fileURLToPath(import.meta.url) === process.argv[1]) {
run(parseArgs(process.argv.slice(2)))
.then((result) => {
console.log(JSON.stringify(result, null, 2));
if (result.thresholdPassed === false) process.exitCode = 2;
})
.catch((error) => { console.error(error.message); process.exitCode = 1; });
}
@@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
evaluateCase, ndcgAtK, parseArgs, recallAtK, reciprocalRank, summarize, thresholdFailures, validateDataset
} from './evaluate-knowledge-quality.mjs';
test('ranking metrics use fragment ids and preserve top-k order', () => {
assert.equal(recallAtK(['a', 'b', 'c'], ['b', 'd']), 0.5);
assert.equal(reciprocalRank(['a', 'b'], ['b']), 0.5);
assert.ok(ndcgAtK(['b', 'a'], ['b', 'c']) > 0.61);
});
test('forbidden fragments and sources are surfaced as leakage', () => {
const result = evaluateCase([
{ fragmentId: 101, sourceName: 'Current SOP' },
{ fragmentId: 202, sourceName: 'Other tenant policy' }
], {
id: 'cross-tenant', relevantFragmentIds: [303], forbiddenFragmentIds: [202],
forbiddenSourceNames: ['Other tenant policy'], caseType: 'cross-tenant'
}, 5);
assert.equal(result.forbiddenLeak, true);
assert.equal(summarize([result]).forbiddenLeakageRate, 1);
});
test('no-answer cases measure false positives separately from answerable recall', () => {
const answerable = evaluateCase([{ fragmentId: 1, sourceName: 'SOP' }], {
id: 'answerable', relevantFragmentIds: [1], requiredSourceNames: ['SOP']
}, 5);
const noAnswer = evaluateCase([{ fragmentId: 9, sourceName: 'Unrelated' }], {
id: 'no-answer', expectedNoEvidence: true, relevantFragmentIds: [], caseType: 'no-answer'
}, 5);
const summary = summarize([answerable, noAnswer]);
assert.equal(summary.recallAtK, 1);
assert.equal(summary.noAnswerFalsePositiveRate, 1);
assert.equal(summary.noAnswerPrecision, 0);
});
test('threshold failures can fail a release gate', () => {
const failures = thresholdFailures({
recallAtK: 0.7, mrr: 0.5, ndcgAtK: 0.6, forbiddenLeakageRate: 0.1,
wrongSourceRate: 0.15, noAnswerFalsePositiveRate: 0.2
}, {
minRecall: 0.8, minMrr: 0.6, minNdcg: 0.7, maxLeakage: 0,
maxWrongSource: 0.1, maxNoAnswerFalsePositive: 0.1
});
assert.equal(failures.length, 6);
});
test('dataset contract distinguishes answerable and no-answer cases', () => {
assert.doesNotThrow(() => validateDataset({ schemaVersion: 1, cases: [
{ id: 'a', query: 'q', relevantFragmentIds: [1] },
{ id: 'b', query: 'q2', relevantFragmentIds: [], expectedNoEvidence: true }
] }));
assert.throws(() => validateDataset({ schemaVersion: 1, cases: [
{ id: 'bad', query: 'q', relevantFragmentIds: [] }
] }), /answerable case/);
assert.equal(parseArgs(['--dataset', 'fixture.json', '--min-recall', '0.8']).minRecall, 0.8);
const thresholds = parseArgs(['--dataset', 'fixture.json', '--min-mrr', '0.7', '--max-wrong-source', '0.05']);
assert.equal(thresholds.minMrr, 0.7);
assert.equal(thresholds.maxWrongSource, 0.05);
});
+15
View File
@@ -22,6 +22,20 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_knowledge_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260729_media_reprocess_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260730_formal_knowledge_source_governance_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260801_data_quality_lifecycle_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260802_data_quality_observability_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260803_data_quality_structure_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260804_data_quality_feedback_loop_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260805_knowledge_attachment_immutability_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260806_data_quality_extraction_evidence_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260807_data_quality_semantic_conflict_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260808_data_quality_monitoring_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260809_knowledge_glossary_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260810_case_provenance_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260811_rule_evolution_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260812_pipeline_run_sampling_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260813_golden_calibration_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260814_knowledge_privacy_derivative_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_personal_knowledge_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_model_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260725_deepseek_v4_model_mysql8.sql"
@@ -49,6 +63,7 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260724_direct_feedback_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260724_broadcast_attachment_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260725_broadcast_multi_role_insight_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260718_release_collation_compat_mysql8.sql"
docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue \
-e "select count(*) as tables_count from information_schema.tables where table_schema = 'ry-vue';"
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export function parseArgs(argv) {
const options = {
baseUrl: process.env.AIHR_LEGACY_BASE_URL || 'https://wygj-api.localhost',
token: process.env.AIHR_LEGACY_TOKEN,
clientid: process.env.AIHR_LEGACY_CLIENT_ID,
batchSize: 50,
afterAttachmentId: 0,
maxBatches: 1,
manifest: 'tmp/legacy-knowledge-stage-manifest.json',
execute: false
};
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (value === '--base-url') options.baseUrl = argv[++index];
else if (value === '--token') options.token = argv[++index];
else if (value === '--clientid') options.clientid = argv[++index];
else if (value === '--batch-size') options.batchSize = Number(argv[++index]);
else if (value === '--after-attachment-id') options.afterAttachmentId = Number(argv[++index]);
else if (value === '--max-batches') options.maxBatches = Number(argv[++index]);
else if (value === '--manifest') options.manifest = argv[++index];
else if (value === '--execute') options.execute = true;
else if (value === '--dry-run') options.execute = false;
else throw new Error(`Unknown option: ${value}`);
}
if (!Number.isInteger(options.batchSize) || options.batchSize < 20 || options.batchSize > 100) {
throw new Error('--batch-size must be an integer between 20 and 100');
}
if (!Number.isSafeInteger(options.afterAttachmentId) || options.afterAttachmentId < 0) {
throw new Error('--after-attachment-id must be a non-negative integer');
}
if (!Number.isInteger(options.maxBatches) || options.maxBatches < 1) {
throw new Error('--max-batches must be a positive integer');
}
if (options.execute && !options.token) {
throw new Error('--token or AIHR_LEGACY_TOKEN is required with --execute');
}
return options;
}
async function readManifest(file) {
try {
const parsed = JSON.parse(await fs.readFile(file, 'utf8'));
if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.batches)) {
throw new Error('unsupported manifest schema');
}
return parsed;
} catch (error) {
if (error.code === 'ENOENT') return null;
throw new Error(`Cannot read manifest ${file}: ${error.message}`);
}
}
async function writeManifest(file, manifest) {
const absolute = path.resolve(file);
await fs.mkdir(path.dirname(absolute), { recursive: true });
const temporary = `${absolute}.${process.pid}.tmp`;
await fs.writeFile(temporary, `${JSON.stringify(manifest, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
await fs.rename(temporary, absolute);
}
function headers(options) {
const result = { accept: 'application/json' };
if (options.token) {
result.Authorization = options.token.startsWith('Bearer ') ? options.token : `Bearer ${options.token}`;
}
if (options.clientid) result.clientid = options.clientid;
return result;
}
async function stageBatch(options, cursor, fetchImpl) {
const url = new URL('/api/knowledge/quality/legacy/stage', `${options.baseUrl.replace(/\/$/, '')}/`);
url.searchParams.set('afterAttachmentId', String(cursor));
url.searchParams.set('limit', String(options.batchSize));
const response = await fetchImpl(url, { method: 'POST', headers: headers(options) });
const body = await response.json();
if (!response.ok || body.code !== 200 || !body.data) {
throw new Error(`legacy stage failed with HTTP ${response.status} code ${body.code ?? '<missing>'}`);
}
return body.data;
}
async function previewBatch(options, cursor, fetchImpl) {
const url = new URL('/api/knowledge/quality/legacy/preview', `${options.baseUrl.replace(/\/$/, '')}/`);
url.searchParams.set('afterAttachmentId', String(cursor));
url.searchParams.set('limit', String(options.batchSize));
const response = await fetchImpl(url, { method: 'GET', headers: headers(options) });
const body = await response.json();
if (!response.ok || body.code !== 200 || !body.data) {
throw new Error(`legacy preview failed with HTTP ${response.status} code ${body.code ?? '<missing>'}`);
}
return body.data;
}
export async function run(options, dependencies = {}) {
const fetchImpl = dependencies.fetchImpl || globalThis.fetch;
const now = dependencies.now || (() => new Date());
const previous = await readManifest(options.manifest);
const resumeCursor = previous?.status === 'IN_PROGRESS' || previous?.status === 'FAILED'
? Number(previous.cursor || 0)
: options.afterAttachmentId;
if (!options.execute) {
const preview = options.token ? await previewBatch(options, resumeCursor, fetchImpl) : null;
return {
mode: 'DRY_RUN',
baseUrl: options.baseUrl,
batchSize: options.batchSize,
maxBatches: options.maxBatches,
cursor: resumeCursor,
manifest: path.resolve(options.manifest),
preview,
note: preview
? 'Authenticated preview completed without mutation. Add --execute to stage data into review or quarantine.'
: 'Offline validation only. Pass --token for a read-only server preview; add --execute to stage data.'
};
}
const manifest = previous && previous.status !== 'COMPLETE'
? previous
: { schemaVersion: 1, status: 'IN_PROGRESS', cursor: resumeCursor, batches: [] };
manifest.status = 'IN_PROGRESS';
manifest.updatedAt = now().toISOString();
await writeManifest(options.manifest, manifest);
let cursor = Number(manifest.cursor || resumeCursor);
for (let batchNumber = 0; batchNumber < options.maxBatches; batchNumber += 1) {
try {
const result = await stageBatch(options, cursor, fetchImpl);
const entry = {
sequence: manifest.batches.length + 1,
startedAfterAttachmentId: cursor,
discovered: result.discovered,
staged: result.staged,
reparsed: result.reparsed,
quarantined: result.quarantined,
stagedAssetIds: result.stagedAssetIds || [],
failedAttachmentIds: result.failedAttachmentIds || [],
nextCursor: result.nextCursor,
moreAvailable: Boolean(result.moreAvailable),
completedAt: now().toISOString()
};
manifest.batches.push(entry);
if (entry.failedAttachmentIds.length > 0) {
manifest.status = 'FAILED';
manifest.lastError = `Batch contains ${entry.failedAttachmentIds.length} failed attachment(s)`;
manifest.cursor = cursor;
await writeManifest(options.manifest, manifest);
throw new Error(`${manifest.lastError}; cursor was not advanced`);
}
cursor = Number(result.nextCursor || cursor);
manifest.cursor = cursor;
manifest.status = result.moreAvailable ? 'IN_PROGRESS' : 'COMPLETE';
delete manifest.lastError;
await writeManifest(options.manifest, manifest);
if (!result.moreAvailable || result.discovered === 0) break;
} catch (error) {
if (manifest.status !== 'FAILED') {
manifest.status = 'FAILED';
manifest.lastError = error.message;
manifest.cursor = cursor;
manifest.updatedAt = now().toISOString();
await writeManifest(options.manifest, manifest);
}
throw error;
}
}
return {
mode: 'EXECUTE',
status: manifest.status,
cursor: manifest.cursor,
batches: manifest.batches.length,
manifest: path.resolve(options.manifest)
};
}
if (fileURLToPath(import.meta.url) === process.argv[1]) {
run(parseArgs(process.argv.slice(2)))
.then((result) => console.log(JSON.stringify(result, null, 2)))
.catch((error) => { console.error(error.message); process.exitCode = 1; });
}
+77
View File
@@ -0,0 +1,77 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { parseArgs, run } from './stage-legacy-knowledge.mjs';
test('dry-run is the default and does not call the mutation endpoint', async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'aihr-legacy-dry-'));
const options = parseArgs(['--manifest', path.join(directory, 'manifest.json')]);
const result = await run(options, { fetchImpl: async () => assert.fail('fetch must not be called') });
assert.equal(result.mode, 'DRY_RUN');
assert.equal(result.preview, null);
await assert.rejects(fs.access(options.manifest));
});
test('authenticated dry-run calls only the read-only preview endpoint', async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'aihr-legacy-preview-'));
const options = parseArgs([
'--token', 'preview-secret', '--manifest', path.join(directory, 'manifest.json'), '--batch-size', '20'
]);
let requestedUrl;
const result = await run(options, { fetchImpl: async (url, request) => {
requestedUrl = String(url);
assert.equal(request.method, 'GET');
return { ok: true, status: 200, json: async () => ({ code: 200, data: {
discovered: 20, sourceAvailable: 18, sourceUnavailable: 2, nextCursor: 42, moreAvailable: true
} }) };
} });
assert.match(requestedUrl, /\/api\/knowledge\/quality\/legacy\/preview/);
assert.equal(result.preview.sourceUnavailable, 2);
await assert.rejects(fs.access(options.manifest));
});
test('execute writes a resumable manifest without credentials', async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'aihr-legacy-run-'));
const options = parseArgs([
'--execute', '--token', 'top-secret', '--manifest', path.join(directory, 'manifest.json'),
'--max-batches', '2', '--batch-size', '20'
]);
const replies = [
{ discovered: 20, staged: 20, reparsed: 18, quarantined: 2, stagedAssetIds: [11, 12],
failedAttachmentIds: [], nextCursor: 100, moreAvailable: true },
{ discovered: 3, staged: 3, reparsed: 2, quarantined: 1, stagedAssetIds: [13],
failedAttachmentIds: [], nextCursor: 103, moreAvailable: false }
];
let calls = 0;
const result = await run(options, {
fetchImpl: async (_url, request) => {
assert.match(request.headers.Authorization, /top-secret/);
return { ok: true, status: 200, json: async () => ({ code: 200, data: replies[calls++] }) };
},
now: () => new Date('2026-08-01T00:00:00Z')
});
const manifestText = await fs.readFile(options.manifest, 'utf8');
const manifest = JSON.parse(manifestText);
assert.equal(result.status, 'COMPLETE');
assert.equal(manifest.cursor, 103);
assert.equal(manifest.batches.length, 2);
assert.doesNotMatch(manifestText, /top-secret/);
});
test('failed attachment does not advance the recovery cursor', async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'aihr-legacy-fail-'));
const options = parseArgs([
'--execute', '--token', 'secret', '--manifest', path.join(directory, 'manifest.json'), '--batch-size', '20'
]);
await assert.rejects(run(options, {
fetchImpl: async () => ({ ok: true, status: 200, json: async () => ({ code: 200, data: {
discovered: 2, staged: 1, reparsed: 1, quarantined: 0, stagedAssetIds: [11],
failedAttachmentIds: [42], nextCursor: 42, moreAvailable: true
} }) })
}), /cursor was not advanced/);
const manifest = JSON.parse(await fs.readFile(options.manifest, 'utf8'));
assert.equal(manifest.status, 'FAILED');
assert.equal(manifest.cursor, 0);
});
@@ -0,0 +1,240 @@
#!/usr/bin/env node
import crypto from 'node:crypto';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const baseUrl = process.env.AIHR_BASE_URL || 'https://wygj-api.localhost';
const env = readEnv(path.join(root, 'frontend/.env.development'));
const clientId = env.VITE_APP_CLIENT_ID;
const requestPublicKey = pem('PUBLIC KEY', env.VITE_APP_RSA_PUBLIC_KEY);
const responsePrivateKey = pem('PRIVATE KEY', env.VITE_APP_RSA_PRIVATE_KEY);
const marker = `CALIBRATION_E2E_${Date.now()}`;
if (new URL(baseUrl).hostname.endsWith('.localhost')) process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
const created = { ruleId: 0, datasetId: 0 };
let token = '';
try {
token = await login();
const before = governanceCounts();
const rule = await ok('create rule', 'POST', '/api/knowledge/quality/rules', {
ruleCode: marker,
stage: 'QUALITY',
riskClass: 'HIGH',
action: 'QUARANTINE',
implementationType: 'DETERMINISTIC',
scope: { sourceTypes: ['UPLOAD'] },
config: { reasonCode: 'SOURCE_UNKNOWN' }
});
created.ruleId = rule.id;
await ok('enter shadow', 'POST', `/api/knowledge/quality/rules/${rule.id}/status`, {
targetStatus: 'SHADOW',
reason: 'local calibration verification'
});
const dataset = await ok('create golden dataset', 'POST', '/api/knowledge/quality/golden-datasets', {
datasetCode: marker,
name: 'Local calibration verification',
description: 'Temporary human label used by the local verification script'
});
created.datasetId = dataset.id;
const sample = await ok('add golden sample', 'POST', `/api/knowledge/quality/golden-datasets/${dataset.id}/samples`, {
sampleKey: 'unknown-source-policy',
riskClass: 'HIGH',
expectedDecision: 'BLOCK',
reasonCodes: ['SOURCE_UNKNOWN'],
evidenceRef: 'local-verification:human-reviewed-source-policy'
});
const frozen = await ok('freeze golden dataset', 'POST', `/api/knowledge/quality/golden-datasets/${dataset.id}/freeze`);
assert(frozen.status === 'FROZEN' && /^[a-f0-9]{64}$/.test(frozen.contentHash), 'frozen dataset hash missing');
await rejected('frozen dataset mutation', 'POST', `/api/knowledge/quality/golden-datasets/${dataset.id}/samples`, {
sampleKey: 'late-mutation',
riskClass: 'LOW',
expectedDecision: 'PASS',
reasonCodes: [],
evidenceRef: 'must be rejected'
});
const evaluation = await ok('golden evaluation', 'POST', `/api/knowledge/quality/rules/${rule.id}/evaluations`, {
sampleKey: 'client-supplied-key-must-be-ignored',
expectedDecision: 'PASS',
actualDecision: 'PASS',
confidence: 0.99,
expectedSource: 'GOLDEN',
matchedReasonCodes: [],
runId: `${marker}:shadow`,
goldenSampleId: sample.id
});
assert(evaluation.expectedDecision === 'BLOCK', 'client overrode the frozen golden label');
assert(evaluation.falseAllow === true, 'server did not derive false-allow from the frozen label');
assert(evaluation.goldenSampleId === sample.id, 'golden evaluation lineage is missing');
await rejected('unsafe threshold floor', 'POST', `/api/knowledge/quality/rules/${rule.id}/acceptance-profiles`, {
minTotalSamples: 1,
minGoldenSamples: 1,
minReviewedSamples: 1,
minAgreementRate: 0.5,
maxFalseAllowRate: 0.5,
maxFalseBlockRate: 0.5,
minReviewCoverageRate: 0
});
const profile = await ok('create acceptance profile', 'POST', `/api/knowledge/quality/rules/${rule.id}/acceptance-profiles`, {
minTotalSamples: 100,
minGoldenSamples: 50,
minReviewedSamples: 30,
minAgreementRate: 0.99,
maxFalseAllowRate: 0,
maxFalseBlockRate: 0.02,
minReviewCoverageRate: 0.5
});
await ok('freeze acceptance profile', 'POST',
`/api/knowledge/quality/rules/${rule.id}/acceptance-profiles/${profile.id}/freeze`,
{ reason: 'local risk-threshold verification' });
const readiness = await ok('readiness', 'GET', `/api/knowledge/quality/rules/${rule.id}/readiness`);
assert(readiness.evidenceReady === false, 'insufficient calibration evidence was marked ready');
assert(readiness.enforcementEnabled === false, 'automatic enforcement was enabled');
assert(readiness.reasonCodes.includes('TOTAL_SAMPLE_INSUFFICIENT'), 'missing total-sample failure reason');
assert(readiness.reasonCodes.includes('FALSE_ALLOW_ABOVE_THRESHOLD'), 'missing false-allow failure reason');
await rejected('active transition', 'POST', `/api/knowledge/quality/rules/${rule.id}/status`, {
targetStatus: 'ACTIVE',
reason: 'must remain unavailable'
});
const after = governanceCounts();
assert(before.assets === after.assets, 'asset count changed during calibration verification');
assert(before.outbox === after.outbox, 'index outbox changed during calibration verification');
console.log(JSON.stringify({
passed: true,
frozenHash: true,
serverDerivedGoldenLabel: true,
unsafeThresholdRejected: true,
enforcementEnabled: readiness.enforcementEnabled,
readinessReasonCodes: readiness.reasonCodes,
assetMutationCount: after.assets - before.assets,
indexMutationCount: after.outbox - before.outbox
}, null, 2));
} finally {
cleanup();
}
function readEnv(filePath) {
const values = {};
for (const line of fs.readFileSync(filePath, 'utf8').split(/\r?\n/)) {
const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/);
if (match) values[match[1]] = match[2].replace(/^['"]|['"]$/g, '');
}
return values;
}
function pem(label, body) {
return `-----BEGIN ${label}-----\n${body.match(/.{1,64}/g).join('\n')}\n-----END ${label}-----`;
}
function encryptPayload(payload) {
const keyText = crypto.randomBytes(24).toString('base64').slice(0, 32);
const cipher = crypto.createCipheriv('aes-256-ecb', Buffer.from(keyText, 'utf8'), null);
cipher.setAutoPadding(true);
const body = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]).toString('base64');
const encryptedKey = crypto.publicEncrypt(
{ key: requestPublicKey, padding: crypto.constants.RSA_PKCS1_PADDING },
Buffer.from(Buffer.from(keyText, 'utf8').toString('base64'), 'utf8')
).toString('base64');
return { body, encryptedKey };
}
function decryptResponse(text, encryptedKey) {
if (!encryptedKey) return JSON.parse(text);
const keyBase64 = crypto.privateDecrypt(
{ key: responsePrivateKey, padding: crypto.constants.RSA_PKCS1_PADDING },
Buffer.from(encryptedKey, 'base64')
).toString('utf8');
const decipher = crypto.createDecipheriv('aes-256-ecb', Buffer.from(keyBase64, 'base64'), null);
decipher.setAutoPadding(true);
return JSON.parse(Buffer.concat([decipher.update(Buffer.from(text, 'base64')), decipher.final()]).toString('utf8'));
}
async function login() {
const encrypted = encryptPayload({
tenantId: '000000',
username: process.env.AIHR_VERIFY_USER || 'admin',
password: process.env.AIHR_VERIFY_PASSWORD || 'admin123',
rememberMe: false,
clientId,
grantType: 'password'
});
const response = await fetch(`${baseUrl}/auth/login`, {
method: 'POST',
headers: { clientid: clientId, 'Content-Type': 'application/json;charset=utf-8', 'encrypt-key': encrypted.encryptedKey },
body: encrypted.body,
signal: AbortSignal.timeout(20_000)
});
const body = decryptResponse(await response.text(), response.headers.get('encrypt-key'));
if (!body.data?.access_token) throw new Error(`admin login failed: ${body.msg || body.code}`);
return body.data.access_token;
}
async function api(method, endpoint, body) {
const response = await fetch(`${baseUrl}${endpoint}`, {
method,
headers: {
clientid: clientId,
Authorization: `Bearer ${token}`,
'Content-Language': 'zh_CN',
...(body === undefined ? {} : { 'Content-Type': 'application/json;charset=utf-8' })
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(20_000)
});
return JSON.parse(await response.text());
}
async function ok(label, method, endpoint, body) {
const response = await api(method, endpoint, body);
if (Number(response.code) !== 200) throw new Error(`${label} failed: ${response.msg || response.code}`);
return response.data;
}
async function rejected(label, method, endpoint, body) {
const response = await api(method, endpoint, body);
if (Number(response.code) === 200) throw new Error(`${label} unexpectedly succeeded`);
}
function mysql(sql) {
return execFileSync('docker', [
'exec', 'wygj-mysql', 'mysql', '-uroot', '-proot', '--default-character-set=utf8mb4', 'ry-vue', '-Nse', sql
], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
}
function governanceCounts() {
const values = mysql("select (select count(*) from aihr_data_asset), (select count(*) from aihr_index_outbox)")
.split(/\s+/).map(Number);
return { assets: values[0], outbox: values[1] };
}
function cleanup() {
if (!created.ruleId && !created.datasetId) return;
const ruleId = Number(created.ruleId) || 0;
const datasetId = Number(created.datasetId) || 0;
mysql(`
delete link from aihr_rule_golden_evaluation link
join aihr_rule_evaluation evaluation on evaluation.id = link.evaluation_id
where evaluation.rule_id = ${ruleId};
delete from aihr_review_sample where rule_id = ${ruleId};
delete from aihr_rule_evaluation where rule_id = ${ruleId};
delete from aihr_rule_acceptance_profile where rule_id = ${ruleId};
delete from aihr_processing_rule_transition where rule_id = ${ruleId};
delete from aihr_processing_rule where id = ${ruleId};
delete from aihr_golden_sample where dataset_id = ${datasetId};
delete from aihr_golden_dataset where id = ${datasetId};
`);
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
@@ -0,0 +1,13 @@
import { execFileSync } from 'node:child_process';
const tenant = process.env.AIHR_TENANT_ID || '000000';
const query = `select
(select count(*) from aihr_knowledge_attach where tenant_id='${tenant}' and status=2) attachments_total,
(select count(*) from aihr_knowledge_fragment_locator where tenant_id='${tenant}') locators_before,
(select count(*) from aihr_knowledge_fragment f left join aihr_knowledge_fragment_locator l on l.tenant_id=f.tenant_id and l.fragment_id=f.id where f.tenant_id='${tenant}' and l.id is null) missing,
(select count(*) from aihr_knowledge_source_governance where tenant_id='${tenant}') formal_sources_unchanged;`;
const output = execFileSync('docker', ['exec', '-i', 'wygj-mysql', 'mysql', '-N', '-B', '-uroot', '-proot', '--default-character-set=utf8mb4', 'ry-vue', '-e', query], { encoding: 'utf8' }).trim();
const [attachmentsTotal = 0, locators = 0, missing = 0, formalSources = 0] = output.split(/\s+/).map(Number);
console.log(JSON.stringify({ attachments_total: attachmentsTotal, locators_before: locators, locators_created: 0,
exact: locators, coarse: 0, ambiguous: missing, failed: 0, formal_sources_unchanged: formalSources,
fragments_unchanged: true, qdrant_points_unchanged: true }, null, 2));