165 lines
11 KiB
JavaScript
165 lines
11 KiB
JavaScript
const assert = require('node:assert/strict')
|
|||
|
|
const crypto = require('node:crypto')
|
||
|
|
const fs = require('node:fs')
|
||
|
|
const path = require('node:path')
|
||
|
|
const { chromium } = require('/Users/macclaw/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
|
||
|
|
|
||
|
|
const root = path.resolve(__dirname, '../..')
|
||
|
|
const origin = process.env.VERIFY_FRONTEND_ORIGIN || 'https://agent.pducv.cn'
|
||
|
|
const apiOrigin = process.env.VERIFY_API_ORIGIN || 'http://127.0.0.1:18080'
|
||
|
|
const kbID = '16c3e59c-4bb6-41cf-ae0a-6d0941b3d032'
|
||
|
|
const report = { checked_at: new Date().toISOString(), origin, api_origin: apiOrigin, checks: [], failures: [] }
|
||
|
|
let auth, browser
|
||
|
|
|
||
|
|
function check(name, value) {
|
||
|
|
report.checks.push({ name, passed: !!value })
|
||
|
|
assert.ok(value, name)
|
||
|
|
}
|
||
|
|
|
||
|
|
async function api(route, requestOrigin = apiOrigin) {
|
||
|
|
const response = await fetch(`${requestOrigin}/api/v1${route}`, {
|
||
|
|
headers: { Authorization: `Bearer ${auth.token}`, 'X-Tenant-ID': '10059' },
|
||
|
|
signal: AbortSignal.timeout(30000),
|
||
|
|
})
|
||
|
|
assert.equal(response.status, 200, `GET ${route}: HTTP ${response.status}`)
|
||
|
|
const result = await response.json()
|
||
|
|
assert.notEqual(result.success, false, `GET ${route} failed`)
|
||
|
|
return result.data
|
||
|
|
}
|
||
|
|
|
||
|
|
async function main() {
|
||
|
|
const credentials = JSON.parse(fs.readFileSync(path.join(root, 'acceptance/super-admin-login.json'), 'utf8'))
|
||
|
|
const login = await fetch(`${apiOrigin}/api/v1/auth/login`, {
|
||
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials),
|
||
|
|
signal: AbortSignal.timeout(30000),
|
||
|
|
})
|
||
|
|
assert.equal(login.status, 200, 'authentication failed')
|
||
|
|
const loginResult = await login.json()
|
||
|
|
auth = loginResult.data || loginResult
|
||
|
|
|
||
|
|
const kbs = await api('/knowledge-bases')
|
||
|
|
report.knowledge_bases = []
|
||
|
|
for (const kb of kbs) {
|
||
|
|
const config = await api(`/initialization/config/${kb.id}`)
|
||
|
|
const initialized = !!(kb.summary_model_id && kb.embedding_model_id && config.llm?.modelName && config.embedding?.modelName)
|
||
|
|
report.knowledge_bases.push({ id: kb.id, name: kb.name, initialized, graph_enabled: !!kb.extract_config?.enabled,
|
||
|
|
llm_model: config.llm?.modelName, embedding_model: config.embedding?.modelName })
|
||
|
|
check(`models_resolve:${kb.name}`, initialized)
|
||
|
|
}
|
||
|
|
const overview = await api(`/knowledge-bases/${kbID}/evidence-graph`)
|
||
|
|
report.graph = { counts: overview.counts, sources: overview.sources, mapped_sources: overview.mapped_sources }
|
||
|
|
check('product_has_extracted_entities_and_relations', overview.counts.knowledgeentity > 2 && overview.counts.knowledgerelation > 0)
|
||
|
|
const nodes = [...overview.nodes]
|
||
|
|
let cursor = overview.next_cursor
|
||
|
|
for (let pages = 0; cursor && pages < 50; pages++) {
|
||
|
|
const next = await api(`/knowledge-bases/${kbID}/evidence-graph?cursor=${cursor}`)
|
||
|
|
nodes.push(...next.nodes)
|
||
|
|
assert.notEqual(next.next_cursor, cursor, 'graph cursor did not advance')
|
||
|
|
cursor = next.next_cursor
|
||
|
|
}
|
||
|
|
assert.equal(cursor, 0, 'graph exceeded verification pagination bound')
|
||
|
|
check('all_entity_ids_are_exact_in_browser', nodes.every(node => Number.isSafeInteger(node.id)))
|
||
|
|
const nativeNodes = nodes.filter(node => node.payload.origin === 'native_extraction_v1')
|
||
|
|
check('native_extraction_is_visible', nativeNodes.length > 0)
|
||
|
|
check('example_entities_are_absent', !nativeNodes.some(node => ['基础概念', '实践练习'].includes(node.payload.canonical_name)))
|
||
|
|
const chunks = new Map(), documents = new Map(), relations = new Map(), evidenceIDs = new Set()
|
||
|
|
let selected
|
||
|
|
for (const node of nativeNodes) {
|
||
|
|
const detail = await api(`/knowledge-bases/${kbID}/evidence-graph?entity=${node.id}`)
|
||
|
|
assert.equal(detail.nodes[0]?.id, node.id, 'entity detail must resolve exact ID')
|
||
|
|
const nativeEvidence = detail.evidence.filter(item => item.payload.origin === 'native_extraction_v1')
|
||
|
|
assert.ok(nativeEvidence.length, `native entity ${node.id} has no evidence`)
|
||
|
|
if (!selected && detail.relations.length) selected = { node, evidence: nativeEvidence[0] }
|
||
|
|
for (const relation of detail.relations) relations.set(relation.id, relation)
|
||
|
|
for (const evidence of nativeEvidence) {
|
||
|
|
assert.equal(evidence.mapping_state, 'native_extraction', 'evidence no longer maps to its original chunk')
|
||
|
|
assert.ok(evidence.native_chunk_id && evidence.native_knowledge_id, 'evidence source link is missing')
|
||
|
|
if (!chunks.has(evidence.native_chunk_id)) chunks.set(evidence.native_chunk_id, await api(`/chunks/by-id/${evidence.native_chunk_id}`))
|
||
|
|
if (!documents.has(evidence.native_knowledge_id)) documents.set(evidence.native_knowledge_id, await api(`/knowledge/${evidence.native_knowledge_id}`))
|
||
|
|
const chunk = chunks.get(evidence.native_chunk_id), document = documents.get(evidence.native_knowledge_id)
|
||
|
|
assert.equal(chunk.knowledge_base_id, kbID, 'chunk belongs to another knowledge base')
|
||
|
|
assert.equal(chunk.knowledge_id, evidence.native_knowledge_id, 'chunk does not belong to the source document')
|
||
|
|
assert.equal(document.knowledge_base_id, kbID, 'source document belongs to another knowledge base')
|
||
|
|
assert.equal(crypto.createHash('sha256').update(chunk.content).digest('hex'), evidence.payload.source_content_hash,
|
||
|
|
'evidence content hash differs from actual chunk')
|
||
|
|
assert.ok(evidence.payload.excerpt && chunk.content.includes(evidence.payload.excerpt), 'evidence excerpt is not from the original chunk')
|
||
|
|
evidenceIDs.add(evidence.id)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
check('native_evidence_resolves_to_unchanged_source', evidenceIDs.size > 0)
|
||
|
|
check('example_relation_is_absent', [...relations.values()].every(relation =>
|
||
|
|
!(relation.payload.from_name === '基础概念' && relation.payload.to_name === '实践练习')))
|
||
|
|
const nativeRelations = [...relations.values()].filter(relation => relation.payload.origin === 'native_extraction_v1')
|
||
|
|
for (const relation of nativeRelations) {
|
||
|
|
assert.ok(typeof relation.payload.evidence === 'string' && relation.payload.evidence.trim(),
|
||
|
|
`native relation ${relation.id} has no source quotation`)
|
||
|
|
assert.ok([...chunks.values()].some(chunk => chunk.content.includes(relation.payload.evidence)),
|
||
|
|
`native relation ${relation.id} quotation is absent from verified source chunks`)
|
||
|
|
}
|
||
|
|
check('native_relations_have_verbatim_source_quotations', nativeRelations.length > 0)
|
||
|
|
report.verified_native = { entities: nativeNodes.length, entity_evidence: evidenceIDs.size,
|
||
|
|
relations: nativeRelations.length, quoted_relations: nativeRelations.length,
|
||
|
|
chunks: chunks.size, documents: documents.size }
|
||
|
|
report.sample_relations = [...relations.values()].slice(0, 5).map(relation => ({
|
||
|
|
from: relation.payload.from_name, relation: relation.payload.relation_type, to: relation.payload.to_name,
|
||
|
|
}))
|
||
|
|
const publicOverview = await api(`/knowledge-bases/${kbID}/evidence-graph`, origin)
|
||
|
|
check('public_graph_api_exposes_completed_extractions', publicOverview.counts.knowledgeentity >= overview.counts.knowledgeentity)
|
||
|
|
const denied = await fetch(`${origin}/api/v1/knowledge-bases/${kbID}/evidence-graph`, { signal: AbortSignal.timeout(30000) })
|
||
|
|
check('public_graph_api_requires_auth', denied.status === 401)
|
||
|
|
|
||
|
|
browser = await chromium.launch({ headless: true, channel: 'chrome' })
|
||
|
|
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||
|
|
await context.addInitScript(value => {
|
||
|
|
const local = { weknora_user: JSON.stringify({ ...value.user, tenant_id: String(value.user.tenant_id) }),
|
||
|
|
weknora_tenant: JSON.stringify({ ...value.active_tenant, id: '10059' }), weknora_token: value.token,
|
||
|
|
weknora_refresh_token: value.refresh_token, weknora_memberships: JSON.stringify(value.memberships), weknora_selected_tenant_id: '10059' }
|
||
|
|
for (const [key, item] of Object.entries(local)) localStorage.setItem(key, item)
|
||
|
|
localStorage.setItem('weknora:new-user-guide-done:v1', '1')
|
||
|
|
}, auth)
|
||
|
|
const page = await context.newPage(), errors = []
|
||
|
|
page.on('pageerror', error => errors.push(error.message))
|
||
|
|
page.on('response', response => {
|
||
|
|
const pathname = new URL(response.url()).pathname
|
||
|
|
if (response.status() >= 400 && /\/(?:api|assets)\//.test(pathname)) errors.push(`${response.status()} ${pathname}`)
|
||
|
|
})
|
||
|
|
await page.goto(`${origin}/platform/knowledge-bases/${kbID}?tab=evidence`)
|
||
|
|
await page.locator('.entity-row').first().waitFor({ timeout: 30000 })
|
||
|
|
assert.ok(selected, 'missing native entity with relation for browser verification')
|
||
|
|
await page.getByRole('textbox', { name: '搜索实体' }).fill(selected.node.payload.canonical_name)
|
||
|
|
await page.getByRole('button', { name: '搜索', exact: true }).click()
|
||
|
|
await page.locator('.entity-row').filter({ hasText: `#${selected.node.id}` }).click()
|
||
|
|
await page.locator('.evidence-row').first().waitFor({ timeout: 30000 })
|
||
|
|
check('native_entity_relations_visible', await page.locator('.relation-list li').count() > 0)
|
||
|
|
check('source_evidence_marked_verified', await page.getByText('已核验原生分块', { exact: true }).count() > 0)
|
||
|
|
await page.screenshot({ path: path.join(root, 'acceptance/business-graph-live-desktop.png'), animations: 'disabled' })
|
||
|
|
await page.getByRole('button', { name: '查看原生文档', exact: true }).first().click()
|
||
|
|
await page.locator('.t-drawer--open').first().waitFor({ timeout: 30000 })
|
||
|
|
const originalDocument = documents.get(selected.evidence.native_knowledge_id)
|
||
|
|
const drawer = page.locator('.t-drawer--open').first()
|
||
|
|
const rawTitle = originalDocument.file_name || originalDocument.title || originalDocument.source
|
||
|
|
const extensionIndex = rawTitle.lastIndexOf('.')
|
||
|
|
const displayTitle = originalDocument.type === 'file' && extensionIndex > 0 ? rawTitle.slice(0, extensionIndex) : rawTitle
|
||
|
|
await page.waitForFunction(title => [...document.querySelectorAll('.t-drawer--open .doc-drawer-header-title')]
|
||
|
|
.some(element => element.textContent.trim() === title), displayTitle, { timeout: 30000 })
|
||
|
|
check('source_document_opens_in_drawer', (await drawer.locator('.doc-drawer-header-title').innerText()).trim() === displayTitle)
|
||
|
|
await page.screenshot({ path: path.join(root, 'acceptance/business-graph-live-source.png'), animations: 'disabled' })
|
||
|
|
await page.keyboard.press('Escape')
|
||
|
|
await page.setViewportSize({ width: 390, height: 844 })
|
||
|
|
await page.screenshot({ path: path.join(root, 'acceptance/business-graph-live-mobile.png'), animations: 'disabled' })
|
||
|
|
check('mobile_graph_has_no_overflow', await page.locator('.evidence-graph').evaluate(element => element.scrollWidth <= element.clientWidth + 2))
|
||
|
|
check('mobile_page_has_no_overflow', await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2))
|
||
|
|
await page.goto(`${origin}/platform/knowledge-bases`)
|
||
|
|
await page.waitForLoadState('networkidle', { timeout: 30000 })
|
||
|
|
check('knowledge_base_list_has_no_initialization_warning', await page.locator('.warning-banner').count() === 0 && await page.locator('.uninitialized').count() === 0)
|
||
|
|
report.browser_errors = errors
|
||
|
|
check('browser_has_no_api_or_runtime_errors', errors.length === 0)
|
||
|
|
}
|
||
|
|
|
||
|
|
main().catch(error => { report.failures.push(error.message); process.exitCode = 1 }).finally(async () => {
|
||
|
|
if (browser) await browser.close()
|
||
|
|
report.passed = report.failures.length === 0
|
||
|
|
fs.writeFileSync(path.join(root, 'acceptance/business-graph-live.json'), JSON.stringify(report, null, 2) + '\n')
|
||
|
|
console.log(JSON.stringify(report))
|
||
|
|
})
|