Files

223 lines
17 KiB
JavaScript
Raw Permalink Normal View History

const assert = require('node:assert/strict')
const http = require('node:http')
const { execFileSync } = require('node:child_process')
const { readFileSync, writeFileSync } = require('node:fs')
const path = require('node:path')
const crypto = require('node:crypto')
const base = 'http://127.0.0.1:18080/api/v1'
const directory = path.resolve(__dirname, '../../acceptance')
const env = Object.fromEntries(readFileSync(path.join(directory, 'runtime.env'), 'utf8').split('\n').filter(Boolean).map(line => { const i = line.indexOf('='); return [line.slice(0,i),line.slice(i+1)] }))
assert.equal(env.DB_NAME, 'yawan_native_acceptance_20260915', 'Tests require the dedicated disposable database')
const sql = text => execFileSync('docker', ['--context','colima-yawan','exec','WeKnora-postgres','psql','-U',env.DB_USER,'-d',env.DB_NAME,'-v','ON_ERROR_STOP=1','-Atc',text], { encoding:'utf8' }).trim()
const checks = [], requests = []
let authorID = ''
async function request(route, token, body, method, status) {
const response = await fetch(base + route,{method: method || (body ? 'POST' : 'GET'),headers:{'Content-Type':'application/json',...(token ? {Authorization:`Bearer ${token}`} : {})},body:body ? JSON.stringify(body):undefined,signal:AbortSignal.timeout(15000)})
const value = await response.json()
assert.equal(response.status, status || (body ? 200 : 200), `${route}: ${JSON.stringify(value)}`)
return value
}
async function account(name) {
const email = `acceptance-${name}-${Date.now()}@example.test`, password = `Test!${crypto.randomBytes(12).toString('hex')}`
await request('/auth/register', null, {username:`验收${name}${crypto.randomBytes(4).toString('hex')}`,email,password},'POST',201)
return request('/auth/login',null,{email,password})
}
async function act(resource, action, args = {}) {
const job = (await request(`/workbench/resources/${resource.kind}/${resource.id}/actions/${action}`,owner.token,{version:resource.version,...args},'POST',202)).data
const started=Date.now()
let result
do {
await new Promise(resolve=>setTimeout(resolve,300))
result=(await request(`/workbench/jobs/${job.id}`,owner.token)).data
} while (['queued','running'].includes(result.status) && Date.now()-started<30000)
assert.equal(result.status,'succeeded',`${action}: ${result.error}`)
return (await request(`/workbench/resources/${resource.kind}/${resource.id}`,owner.token)).data
}
let owner
const server = http.createServer(async (req,res)=>{
let input=''; for await (const chunk of req) input+=chunk
const body=JSON.parse(input), system=body.messages[0].content, prompt=body.messages.at(-1).content
requests.push({system,prompt})
let output
if(system.includes('数字人表达档案蒸馏引擎')) {
const fields=JSON.parse(system.slice(system.indexOf('{'),system.indexOf('}')+1))
const data=JSON.parse(prompt)
output=JSON.stringify(Object.fromEntries(Object.keys(fields).map(key=>[key, `${data.previous_draft?.[key] || ''}材料依据:${data.sources.map(source=>source.Text || source.text).join(';')}`])))
await new Promise(resolve=>setTimeout(resolve,2500))
} else if(system.includes('课程架构师')) {
output=JSON.stringify({days:[{day:1,title:'基础判断',hook:'如何进入实践',prerequisite:'',nodes:[{id:'n1',label:'建立概念'}]},{day:2,title:'实际应用',prerequisite:'掌握第一天的概念',hook:'总结复盘',nodes:[{id:'n2',label:'案例迁移'}]}]})
if(prompt.includes('只修改第 2 天')) output=JSON.stringify({days:[JSON.parse(output).days[1]]})
if(system.includes('Markdown 节点框架')) output=JSON.stringify({framework:'- 核心概念\n - 判断依据\n - 应用场景'})
} else if(system.includes('内容编排师')) {
output=JSON.stringify({segments:[{id:'s1',title:'建立概念',agent_id:authorID},{id:'s2',title:'可选案例',agent_id:authorID},{id:'s3',title:'迁移练习',agent_id:authorID}]})
} else if(system.includes('跨稿质检员')) output=JSON.stringify({passed:true,summary:'衔接一致,无重复讲授。',issues:[]})
else if(system.includes('成稿编辑')) output='确认的基础内容。承接基础后的迁移练习。'
else output=prompt.includes('可选案例') && prompt.includes('当前内容段:可选案例') ? 'SKIPPED_SENTINEL 不应进入后续上下文。' : '采用正文:先建立判断依据,再进入实际应用。'
res.writeHead(200,{'Content-Type':'application/json'})
res.end(JSON.stringify({id:'acceptance',object:'chat.completion',created:Math.floor(Date.now()/1000),model:'acceptance-model',choices:[{index:0,message:{role:'assistant',content:output},finish_reason:'stop'}],usage:{prompt_tokens:20,completion_tokens:20,total_tokens:40}}))
})
async function main() {
await new Promise(resolve=>server.listen(18081,'0.0.0.0',resolve))
let ready=false
for(let attempt=0;attempt<40;attempt++) {
try { ready=(await fetch('http://127.0.0.1:18080/health',{signal:AbortSignal.timeout(1000)})).ok } catch {}
if(ready) break
await new Promise(resolve=>setTimeout(resolve,500))
}
assert.ok(ready,'Acceptance backend did not become healthy')
owner=await account('主用户')
const outsider=await account('隔离用户')
const contributor=await account('协作者')
const viewer=await account('只读用户')
const tenant=owner.active_tenant.id
for(const [login,role] of [[contributor,'contributor'],[viewer,'viewer']]) {
assert.match(login.user.id,/^[a-f\d-]+$/i)
sql(`INSERT INTO tenant_members(user_id,tenant_id,role,status,created_at,updated_at) VALUES ('${login.user.id}',${Number(tenant)},'${role}','active',now(),now())`)
Object.assign(login,await request('/auth/switch-tenant',login.token,{tenant_id:tenant}))
}
checks.push('真实注册、登录及切换空间')
const model=(await request('/models',owner.token,{name:'acceptance-model',type:'KnowledgeQA',source:'remote',parameters:{base_url:'http://host.docker.internal:18081/v1',api_key:'acceptance-only',provider:'generic'}},'POST',201)).data
const author=(await request('/agents',owner.token,{name:'验收课程作者',config:{model_id:model.id,system_prompt:'中文课程作者',agent_mode:'quick-answer'}},'POST',201)).data
authorID=author.id
async function importSkill(login,text='SKILL_ACCEPTANCE_RULE:先给判断依据,再给实践练习。',status=201) {
const form=new FormData()
form.set('file',new Blob([text],{type:'text/markdown'}),'课程结构.md')
form.set('description','验收用文字技能')
const response=await fetch(`${base}/skills/instructions/import`,{method:'POST',headers:{Authorization:`Bearer ${login.token}`},body:form,signal:AbortSignal.timeout(15000)})
const value=await response.json()
assert.equal(response.status,status,`MD import: ${JSON.stringify(value)}`)
return value.data
}
const skill=await importSkill(owner)
assert.match((await request(`/skills/instructions/${skill.id}`,owner.token)).data.instructions,/SKILL_ACCEPTANCE_RULE/)
await request(`/skills/instructions/${skill.id}`,outsider.token,null,'GET',404)
await request(`/skills/instructions/${skill.id}`,contributor.token,null,'GET',404)
assert.ok(!(await request('/skills/instructions',contributor.token)).data.some(row=>row.id===skill.id))
await importSkill(viewer,'not allowed',403)
const privateSkill=await importSkill(contributor,'协作者的同名私人规则。')
assert.notEqual(privateSkill.id,skill.id)
const secondAuthor=(await request('/agents',owner.token,{name:'验收第二作者',config:{model_id:model.id,system_prompt:'第二作者',agent_mode:'quick-answer'}},'POST',201)).data
const mounted=(await request('/skills/instructions/mount',owner.token,{agent_ids:[author.id,secondAuthor.id],skill_ids:[skill.id]})).data
assert.equal(mounted.length,2)
for(const agent of [author,secondAuthor]) assert.deepEqual((await request(`/agents/${agent.id}`,owner.token)).data.config.instruction_skill_ids,[skill.id])
await request('/skills/instructions/mount',contributor.token,{agent_ids:[author.id],skill_ids:[privateSkill.id]},'POST',403)
await request('/skills/instructions/mount',owner.token,{agent_ids:[author.id],skill_ids:[privateSkill.id]},'POST',403)
await request('/skills/instructions/mount',owner.token,{agent_ids:[secondAuthor.id],skill_ids:[skill.id],mode:'remove'})
assert.deepEqual((await request(`/agents/${secondAuthor.id}`,owner.token)).data.config.instruction_skill_ids||[],[])
checks.push('MD技能导入与同名隔离;单个/多个Agent挂载及移除;只读、跨空间和跨用户权限拦截')
const create=async(kind,name,data,login=owner)=>(await request(`/workbench/resources/${kind}`,login.token,{name,data},'POST',201)).data
const flow=await create('flows','验收画布',{nodes:[{id:'writer',agent_id:authorID,label:'内容作者'}],edges:[]})
let map=await create('training-maps','验收训练营',{topic:'学习方法',days_count:2,agent_id:authorID,knowledge_base_ids:[]})
await request(`/workbench/resources/training-maps/${map.id}`,outsider.token,null,'GET',404)
await request(`/workbench/resources/training-maps/${map.id}`,contributor.token,null,'GET',404)
await request('/workbench/resources/flows',viewer.token,{name:'不能创建',data:flow.data},'POST',403)
const owned=await create('flows','协作者私人画布',flow.data,contributor)
await request(`/workbench/resources/flows/${owned.id}`,owner.token)
await request(`/workbench/resources/flows/${owned.id}`,owner.token,{name:'不允许代改',data:owned.data,version:owned.version},'PUT',404)
await request(`/workbench/resources/flows/${owned.id}?detach=true`,owner.token,null,'DELETE',404)
const privateMap=await create('training-maps','协作者私人导图',{topic:'私有主题',days_count:2,agent_id:authorID,knowledge_base_ids:[]},contributor)
await request(`/workbench/resources/training-maps/${privateMap.id}/actions/generate`,owner.token,{version:privateMap.version},'POST',404)
checks.push('跨空间及同空间其他用户不可读取私人内容;只读账号不可写;管理员可查看')
map=await act(map,'generate')
assert.equal(map.data.days.length,2)
assert.ok(requests.at(-1).system.includes('SKILL_ACCEPTANCE_RULE'),'Mounted skill must reach actual model HTTP request')
checks.push('挂载的文字技能实际参与导图模型请求')
map=await act(map,'generate-day',{day:2})
map=await act(map,'confirm-day',{day:2})
map=await act(map,'confirm')
let task=await create('content-tasks','验收内容任务',{map_id:map.id,day:2,flow_id:flow.id,agent_id:authorID,knowledge_base_ids:[]})
task=await act(task,'decompose')
task=await act(task,'generate',{segment_id:'s1'})
task=await act(task,'accept',{segment_id:'s1'})
task=await act(task,'generate',{segment_id:'s2'})
task=await act(task,'skip',{segment_id:'s2'})
task=await act(task,'generate',{segment_id:'s3'})
assert.ok(!requests.at(-1).prompt.includes('SKIPPED_SENTINEL'))
task=await act(task,'accept',{segment_id:'s3'})
task=await act(task,'review')
assert.match(task.data.review,/跨稿质检/)
task=await act(task,'compose')
assert.equal(task.status,'completed')
checks.push('导图生成、第二天框架、确认、拆解、按段生成、跳过衔接、AI质检及成稿')
await request(`/workbench/resources/content-tasks/${task.id}`,owner.token,{name:task.name,data:task.data,version:1},'PUT',409)
await request(`/workbench/resources/flows/${flow.id}`,owner.token,null,'DELETE',409)
await request(`/workbench/resources/flows/${flow.id}?detach=true`,owner.token,null,'DELETE',200)
assert.equal((await request(`/workbench/resources/content-tasks/${task.id}`,owner.token)).data.status,'needs_configuration')
await request(`/workbench/resources/flows/${flow.id}`,owner.token,null,'GET',404)
checks.push('版本冲突拦截;绑定提示及一键解绑删除,删除后不复现')
await request('/workbench/notifications/read-all',owner.token,{},'POST',200)
assert.ok((await request('/workbench/notifications',owner.token)).data.every(row=>row.read))
checks.push('通知一键已读')
let human=(await request('/digital-humans',owner.token,{name:'验收数字人',description:'持续蒸馏验收',model_id:model.id},'POST',201)).data
assert.equal(human.bindings.length,1)
const primaryID=human.primary_agent_id
assert.equal((await request(`/agents/${primaryID}`,owner.token)).data.name,human.name)
assert.equal(human.creator_name,owner.user.username)
await request(`/agents/${primaryID}`,contributor.token,null,'GET',404)
await request(`/agents/${primaryID}`,owner.token,null,'DELETE',409)
await request(`/digital-humans/${human.id}`,outsider.token,null,'GET',404)
await request(`/digital-humans/${human.id}`,contributor.token,null,'GET',404)
await request('/digital-humans',viewer.token,{name:'只读账号不能创建'},'POST',403)
const privateHuman=(await request('/digital-humans',contributor.token,{name:'协作者数字人',model_id:model.id},'POST',201)).data
await request(`/digital-humans/${privateHuman.id}`,owner.token)
await request(`/digital-humans/${privateHuman.id}`,owner.token,null,'DELETE',404)
const readHuman=async()=> (await request(`/digital-humans/${human.id}`,owner.token)).data
async function finishDistill(job) {
const started=Date.now()
let result
do {
await new Promise(resolve=>setTimeout(resolve,200))
result=(await request(`/digital-humans/${human.id}/jobs/${job.id}`,owner.token)).data
} while(result.status==='running' && Date.now()-started<30000)
assert.equal(result.status,'completed',result.error)
human=await readHuman()
assert.equal(human.processing_job_id,'')
return result
}
const materials=new FormData()
materials.set('revision',String(human.revision))
materials.set('model_id',model.id)
materials.set('advice','保留真实表达习惯')
materials.append('files',new Blob(['先建立判断依据,再引导实践。'],{type:'text/markdown'}),'原稿.md')
const uploaded=await fetch(`${base}/digital-humans/${human.id}/distill`,{method:'POST',headers:{Authorization:`Bearer ${owner.token}`},body:materials,signal:AbortSignal.timeout(15000)})
assert.equal(uploaded.status,202)
const initialJob=(await uploaded.json()).data
await request(`/digital-humans/${human.id}/distill`,owner.token,{revision:human.revision,sources:[{name:'冲突',text:'不能并发'}]},'POST',409)
await request(`/digital-humans/${human.id}`,owner.token,null,'DELETE',409)
await finishDistill(initialJob)
assert.equal(human.active_version,0)
human=(await request(`/digital-humans/${human.id}/review`,owner.token,{version:human.latest_version,approve:true})).data
assert.equal(human.active_version,human.latest_version)
assert.match((await request(`/agents/${primaryID}`,owner.token)).data.config.system_prompt,/先建立判断依据/)
const extraAgent=(await request('/agents',owner.token,{name:'验收第二个绑定',config:{model_id:model.id,system_prompt:'保留原职责',agent_mode:'quick-answer'}},'POST',201)).data
await request(`/digital-humans/${human.id}/bindings`,owner.token,{agent_id:extraAgent.id})
human=await readHuman()
assert.equal(human.bindings.length,2)
const nextJob=(await request(`/digital-humans/${human.id}/distill`,owner.token,{revision:human.revision,sources:[{name:'重复原稿',text:'先建立判断依据,再引导实践。'},{name:'增量原稿',text:'用案例回顾并承接上一章节。'}],advice:'补充叙事衔接'},'POST',202)).data
await finishDistill(nextJob)
const savedCount=sql(`SELECT jsonb_array_length(sources) FROM digital_human_versions WHERE digital_human_id='${human.id}' AND number=${Number(human.latest_version)}`)
assert.equal(Number(savedCount),2)
human=(await request(`/digital-humans/${human.id}/review`,owner.token,{version:human.latest_version,approve:true})).data
for(const id of [primaryID,extraAgent.id]) {
const prompt=(await request(`/agents/${id}`,owner.token)).data.config.system_prompt
assert.match(prompt,/先建立判断依据/)
assert.match(prompt,/用案例回顾/)
}
checks.push('数字人创建自动同名Agent;MD上传蒸馏;增量材料去重;审核后同步多个Agent;并发与身份隔离')
await request(`/digital-humans/${human.id}`,owner.token,null,'DELETE')
await request(`/digital-humans/${human.id}`,owner.token,null,'GET',404)
await request(`/agents/${primaryID}`,owner.token,null,'GET',404)
assert.equal((await request(`/agents/${extraAgent.id}`,owner.token)).data.config.system_prompt,'保留原职责')
checks.push('数字人删除联动默认Agent,其他Agent解除绑定并保留职责')
writeFileSync(path.join(directory,'browser-auth.json'),JSON.stringify(owner),{mode:0o600})
if(process.env.WB_BROWSER === '1') {
checks.push(await require('./workbench_digital_browser.cjs')(owner,directory))
checks.push(await require('./workbench_content_browser.cjs')(owner,directory,map))
}
writeFileSync(path.join(directory,'result.json'),JSON.stringify({time:new Date().toISOString(),checks,model:'deterministic HTTP stub, not a live AI quality benchmark',calls:requests.length},null,2))
console.log(JSON.stringify({checks,calls:requests.length},null,2))
}
main().catch(error=>{console.error(error.message);process.exitCode=1}).finally(()=>server.close())