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