Files
xinmi/scripts/verify_business_graph_semantics.cjs
T

184 lines
14 KiB
JavaScript
Raw Normal View History

const assert = require('node:assert/strict')
const crypto = require('node:crypto')
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const kb = '16c3e59c-4bb6-41cf-ae0a-6d0941b3d032'
const problemChunk = '1144acfb-18de-4687-8bf5-df0e9c8f1dd5'
const outputPath = path.resolve(__dirname, '../../acceptance/business-graph-semantics.json')
const report = { checked_at: new Date().toISOString(), production_write: false, checks: [], failures: [] }
const query = `BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT json_build_object(
'config',(SELECT extract_config FROM knowledge_bases WHERE tenant_id=10059 AND id='${kb}' AND deleted_at IS NULL),
'chunks',COALESCE((SELECT json_agg(json_build_object('id',c.id,'knowledge_id',c.knowledge_id,'content',c.content,'file_name',k.file_name) ORDER BY c.id)
FROM chunks c JOIN knowledges k ON k.id=c.knowledge_id AND k.tenant_id=c.tenant_id AND k.knowledge_base_id=c.knowledge_base_id
WHERE c.tenant_id=10059 AND c.knowledge_base_id='${kb}' AND c.chunk_type='text' AND c.is_enabled=true AND c.deleted_at IS NULL AND k.deleted_at IS NULL),'[]'),
'records',COALESCE((SELECT json_agg(json_build_object('id',legacy_id,'table',source_table,'payload',payload) ORDER BY source_table,legacy_id)
FROM native_graph_records WHERE tenant_id=10059 AND knowledge_base_id='${kb}' AND payload->>'origin'='native_extraction_v1'),'[]'),
'sources',COALESCE((SELECT json_agg(json_build_object('source_chunk_id',source_chunk_id,'native_chunk_id',native_chunk_id,'native_knowledge_id',native_knowledge_id,'source_content_hash',source_content_hash,'native_content_sha256',native_content_sha256,'mapping_state',mapping_state))
FROM native_graph_sources WHERE tenant_id=10059 AND knowledge_base_id='${kb}' AND mapping_state='native_extraction'),'[]')
);
COMMIT;`
function check(name, passed) {
report.checks.push({ name, passed: !!passed })
if (!passed) report.failures.push(name)
}
try {
const input = JSON.parse(execFileSync('docker', ['--context', 'colima-yawan', 'exec', '-i', 'WeKnora-postgres',
'psql', '-X', '-qAt', '-v', 'ON_ERROR_STOP=1', '-U', 'postgres', '-d', 'yawan_native_acceptance_20260915'],
{ input: query, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 }))
const chunks = new Map(input.chunks.map(chunk => [chunk.id, chunk]))
const hashes = new Map(input.chunks.map(chunk => [chunk.id, crypto.createHash('sha256').update(chunk.content).digest('hex')]))
const sources = new Map(input.sources.map(source => [source.source_chunk_id, source]))
const entities = new Map(input.records.filter(record => record.table === 'knowledgeentity').map(record => [record.id, record]))
const relations = input.records.filter(record => record.table === 'knowledgerelation')
const states = input.records.filter(record => record.table === 'knowledgegraphchunkstate')
const evidence = input.records.filter(record => record.table === 'knowledgegraphevidence')
const labels = new Set(input.config.tags)
report.counts = { source_chunks: chunks.size, checkpoints: states.length, entities: entities.size,
relations: relations.length, evidence_records: evidence.length }
check('all_51_product_chunks_are_present', chunks.size === 51)
check('all_51_product_chunks_are_checkpointed', states.length === 51 && new Set(states.map(state => state.payload.native_chunk_id)).size === 51)
for (const state of states) {
const chunk = chunks.get(state.payload.native_chunk_id)
check(`checkpoint_current:${state.payload.native_chunk_id}`, chunk
&& state.payload.native_knowledge_id === chunk.knowledge_id
&& state.payload.source_content_hash === hashes.get(chunk.id)
&& state.payload.extraction_status === 'complete')
}
const relationEvidence = new Map()
for (const record of evidence) {
const item = record.payload, source = sources.get(item.source_chunk_id), chunk = chunks.get(source?.native_chunk_id)
check(`evidence_grounded:${record.id}`, chunk && source.mapping_state === 'native_extraction'
&& source.native_knowledge_id === chunk.knowledge_id && item.native_knowledge_id === chunk.knowledge_id
&& item.source_content_hash === hashes.get(chunk.id) && source.source_content_hash === hashes.get(chunk.id)
&& source.native_content_sha256 === hashes.get(chunk.id)
&& typeof item.excerpt === 'string' && item.excerpt.trim().length > 0 && chunk.content.includes(item.excerpt))
if (item.subject_type === 'relation') {
if (!relationEvidence.has(item.subject_id)) relationEvidence.set(item.subject_id, [])
relationEvidence.get(item.subject_id).push({ evidence_id: record.id, chunk_id: chunk?.id, quote: item.excerpt })
}
}
report.relations = relations.map(record => {
const item = record.payload, quotations = relationEvidence.get(record.id) || []
const from = entities.get(item.from_entity_id), to = entities.get(item.to_entity_id)
check(`relation_has_grounded_quote:${record.id}`, from && to && labels.has(item.relation_type)
&& typeof item.evidence === 'string' && item.evidence.trim().length > 0
&& quotations.length > 0 && quotations.some(quotation => quotation.quote === item.evidence))
return { id: record.id, from_id: item.from_entity_id, to_id: item.to_entity_id,
from: from?.payload.canonical_name, relation: item.relation_type,
to: to?.payload.canonical_name, evidence: quotations }
})
const relationChunks = new Set(report.relations.flatMap(relation => relation.evidence.map(item => item.chunk_id)))
report.counts.chunks_with_relations = relationChunks.size
report.counts.checkpointed_without_relations = states.filter(state => !relationChunks.has(state.payload.native_chunk_id)).length
report.counts.unresolved_chunks = [...chunks.keys()].filter(id => !relationChunks.has(id)).length
for (const state of states) {
check(`completed_checkpoint_has_relations:${state.payload.native_chunk_id}`, relationChunks.has(state.payload.native_chunk_id))
}
const problemRelations = report.relations.filter(relation => relation.evidence.some(item => item.chunk_id === problemChunk))
const expectedRestrictions = [
['过敏者', ['慎用于']],
['粘膜破损处', ['禁用于', '不适用于', '不能用于']],
['性生活中对性病的预防', ['禁用于', '不适用于', '不能用于']],
]
for (const [target, allowed] of expectedRestrictions) {
const candidates = problemRelations.filter(relation => relation.to === target || relation.from === target)
check(`known_restriction_preserved:${target}`, candidates.length > 0 && candidates.every(relation =>
relation.from === '本品' && relation.to === target && allowed.includes(relation.relation)))
}
const compact = value => value.replace(/\s+/g, '')
const quantities = value => new Set((compact(value).toLowerCase().match(/[0-9]+(?:[..·][0-9]+)*(?:ppm|ppb|kg|mg|ug|ml|cm|mm|min|g|l|%|%|毫克|千克|克|毫升|升|分钟|小时|天|颗|粒|瓶|支|片)?/g) || [])
.map(value => value.replace(/[.·]/g, '.').replace(/%/g, '%')))
const quantityGrounded = (endpoint, source) => [...quantities(endpoint)].every(value => quantities(source).has(value))
const prohibition = /禁|不得|不能|不可|不宜|不应|不建议|不适用|不适合|不用于|不具有|不具备|避开|请勿|勿用|停用|停止使用/
const affirmative = new Set(['适用于', '适合', '用于', '可用于', '条件适用于'])
const genericEntities = new Map()
for (const relation of report.relations) {
const endpoints = compact(relation.from + relation.to)
for (const [id, name] of [[relation.from_id, relation.from], [relation.to_id, relation.to]]) {
const localName = compact(name).toLowerCase()
if (!['本品', '该产品', '本产品', '产品', '本商品', '此产品', '本公司', '该公司', '本文', '本方法', '该方法', 'thisproduct', 'theproduct'].includes(localName)
&& !localName.endsWith('未名产品') && !localName.startsWith('本品(')) continue
if (!genericEntities.has(id)) genericEntities.set(id, new Set())
for (const item of relation.evidence) genericEntities.get(id).add(item.chunk_id)
}
for (const item of relation.evidence) {
const quote = compact(item.quote)
const source = chunks.get(item.chunk_id)?.content || ''
check(`endpoint_quantities_grounded:${relation.id}:${item.chunk_id}`,
quantityGrounded(relation.from, source) && quantityGrounded(relation.to, item.quote))
if (relation.relation === '抑制') {
const target = compact(relation.to)
check(`suppression_has_target_predicate:${relation.id}:${item.chunk_id}`, quote.includes(target)
&& /抑制|杀灭|杀菌|抑菌|抗菌/.test(quote.split(target).join('')))
}
if (affirmative.has(relation.relation)) {
check(`suitability_has_use_claim:${relation.id}:${item.chunk_id}`, /适用|适合|用于|可用|使用|涂于|涂抹|推入|推进|擦拭|清洁|饮用|服用|外用|内服|口服/.test(quote))
check(`storage_not_suitability:${relation.id}:${item.chunk_id}`, !/贮存|贮藏|储存|储藏|保存|存放/.test(relation.to)
&& !(/阴凉|干燥处|冷藏|常温|避光|遮光|密封/.test(relation.to) && /贮存|贮藏|储存|储藏|保存|存放/.test(quote)))
}
if (['禁用于', '不适用于', '不能用于'].includes(relation.relation)) {
check(`storage_not_usage_prohibition:${relation.id}:${item.chunk_id}`,
!(/阳光|直晒|阴凉|干燥|冷藏|常温|避光|遮光|密封/.test(relation.to)
&& /贮存|贮藏|储存|储藏|保存|存放/.test(quote)))
}
if (relation.relation === '约束') {
if (/避免.{0,16}(?:同用|合用)|不[宜可得能].{0,16}(?:同用|合用)/.test(quote) && /同用|合用|拮抗/.test(endpoints)) {
check(`constraint_preserves_combination_prohibition:${relation.id}:${item.chunk_id}`, /避免|禁止|不得|不能|不可|不宜|不要|勿/.test(endpoints))
}
if (/停止使用|立即停用/.test(quote) && /不适|使用|停用/.test(endpoints)) {
check(`constraint_preserves_stop_action:${relation.id}:${item.chunk_id}`, /停止使用|立即停用/.test(endpoints))
}
if (/儿童不[易宜能]触及|儿童触及不到/.test(quote) && /儿童/.test(endpoints)) {
check(`constraint_preserves_child_storage_action:${relation.id}:${item.chunk_id}`, /不[易宜能]触及|触及不到|远离|避免/.test(endpoints))
}
}
const nameAt = source.indexOf(relation.from), quoteAt = source.indexOf(item.quote)
if (nameAt >= 0 && quoteAt >= 0 && quoteAt + item.quote.length < nameAt && !source.slice(nameAt).includes(item.quote)) {
const boundary = source.lastIndexOf('\n\n', nameAt)
check(`no_later_product_attribution:${relation.id}:${item.chunk_id}`, boundary < quoteAt + item.quote.length
|| !/产品名称|品名/.test(source.slice(boundary)))
}
if (item.chunk_id === '3626de4e-88a2-41e4-8e09-05c51f23848a' && /避开女性崆期|产期/.test(quote)) {
check(`avoidance_not_permission:${relation.id}`, !affirmative.has(relation.relation)
&& relation.relation !== '慎用于')
if (['禁用于', '不适用于', '不能用于', '约束'].includes(relation.relation)) {
check(`vaginal_restriction_scope_preserved:${relation.id}`, endpoints.includes('阴道'))
}
}
if (quote.includes('哺乳期请勿在乳头使用') && /哺乳期|乳头/.test(endpoints)
&& ['禁用于', '不适用于', '不能用于', '约束'].includes(relation.relation)) {
check(`lactation_restriction_scope_preserved:${relation.id}`, endpoints.includes('哺乳期') && endpoints.includes('乳头'))
}
if (/抑制|杀灭|抑菌|杀菌|抗菌/.test(quote) && /菌$|病毒$|微生物$/.test(compact(relation.to))
&& !/抑制|杀灭|抑菌|杀菌|抗菌/.test(compact(relation.to))) {
check(`suppression_not_support:${relation.id}`, !['支持', '促进', '增强'].includes(relation.relation))
}
if (/告别冷淡|改善粗糙与干燥/.test(quote) && !prohibition.test(quote)) {
check(`benefit_not_prohibition:${relation.id}`, !['禁用于', '不适用于', '不能用于', '慎用于', '不支持', '不导致'].includes(relation.relation))
}
if (quote.includes('停止使用') && relation.to.includes('不适')) {
check(`stop_use_not_caution:${relation.id}`, !affirmative.has(relation.relation) && relation.relation !== '慎用于')
}
}
}
for (const [id, chunkIDs] of genericEntities) {
check(`generic_product_is_chunk_scoped:${id}`, chunkIDs.size <= 1)
}
report.problem_chunk_relations = problemRelations
report.chunks = input.chunks.map(chunk => ({ ...chunk, sha256: hashes.get(chunk.id),
relations: report.relations.filter(relation => relation.evidence.some(item => item.chunk_id === chunk.id)) }))
report.semantic_review_note = 'Automated checks cover exact-source evidence, original contraindications, numeric endpoints, product attribution and generic-entity scope, explicit suitability versus storage, and demonstrated polarity reversals. Full semantic correctness still requires reviewing each listed relation with its source chunk.'
} catch (error) {
report.failures.push(error.message)
}
report.passed = report.failures.length === 0
fs.writeFileSync(outputPath, JSON.stringify(report, null, 2) + '\n')
console.log(JSON.stringify({ checked_at: report.checked_at, production_write: report.production_write,
passed: report.passed, counts: report.counts, failures: report.failures, report_path: outputPath }))
if (!report.passed) process.exitCode = 1