feat: govern knowledge assets and source citations
This commit is contained in:
@@ -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' });
|
||||
@@ -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" \
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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';"
|
||||
|
||||
@@ -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; });
|
||||
}
|
||||
@@ -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));
|
||||
Reference in New Issue
Block a user