Files
xinmi/scripts/workbench_acceptance.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

223 lines
17 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 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())