Files
xinmi/scripts/verify_business_graph_semantics.cjs
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

184 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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