270 lines
19 KiB
JavaScript
270 lines
19 KiB
JavaScript
const assert = require('node:assert/strict')
|
|||
|
|
const crypto = require('node:crypto')
|
||
|
|
const fs = require('node:fs')
|
||
|
|
const path = require('node:path')
|
||
|
|
const { execFileSync } = require('node:child_process')
|
||
|
|
|
||
|
|
const directory = path.resolve(__dirname, '../../acceptance')
|
||
|
|
const reportPath = path.join(directory, 'current-generation-20260921.json')
|
||
|
|
const base = 'http://127.0.0.1:18080/api/v1'
|
||
|
|
const tenant = 10059
|
||
|
|
const jobLimitMS = 6 * 60 * 1000
|
||
|
|
const personaFields = 'persona_name identity tone pace vocab taboo opening_habit emotion_style interaction expertise_border sample_excerpt writing_style narrative_style thinking_style audience_profile hook_patterns pain_point_hooks itch_point_hooks sales_logic cta_transition expression_prompt expression_rules'.split(' ')
|
||
|
|
const sha256 = value => crypto.createHash('sha256').update(value).digest('hex')
|
||
|
|
const literal = value => "'" + String(value).replaceAll("'", "''") + "'"
|
||
|
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||
|
|
|
||
|
|
function materials(start = 1, count = 99) {
|
||
|
|
return Array.from({ length: count }, (_, index) => {
|
||
|
|
const number = start + index
|
||
|
|
return { name: `style-${String(number).padStart(3, '0')}.txt`, text: `Writing sample ${number}: I explain purchase decision ${number} with a calm question, a concrete example, and a short recap.` }
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
function sourceSummary(sources) {
|
||
|
|
return sources.map(source => ({ name: source.name, sha256: sha256(source.text.trim()), runes: [...source.text.trim()].length }))
|
||
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||
|
|
}
|
||
|
|
|
||
|
|
function versionWithSources(versions, number, expected) {
|
||
|
|
const version = versions.find(value => value.number === number)
|
||
|
|
assert.ok(version, 'The generated version exists')
|
||
|
|
assert.deepEqual(sourceSummary(version.sources || []), sourceSummary(expected), 'Every submitted source is preserved without truncation')
|
||
|
|
assert.ok(personaFields.every(field => typeof version.persona?.[field] === 'string'), 'All persona dimensions are saved')
|
||
|
|
return version
|
||
|
|
}
|
||
|
|
|
||
|
|
function cleanupBlocker(fixture, fixtures) {
|
||
|
|
if (!fixture.archived) return 'archive_missing'
|
||
|
|
if (fixture.uncertain) return 'request_outcome_uncertain'
|
||
|
|
if (fixture.jobs.some(job => !['succeeded', 'completed', 'failed', 'cancelled'].includes(job.status))) return 'job_not_terminal'
|
||
|
|
if (fixtures.some(other => other.depends_on.includes(fixture.id) && !other.deleted)) return 'dependent_fixture_retained'
|
||
|
|
return ''
|
||
|
|
}
|
||
|
|
|
||
|
|
async function main() {
|
||
|
|
if (process.env.RUN_CURRENT_GENERATION !== '1') {
|
||
|
|
console.log('Prepared only. RUN_CURRENT_GENERATION=1 is required for production fixture creation and model calls.')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
const prefix = `current-generation-${Date.now()}-${crypto.randomBytes(3).toString('hex')}`
|
||
|
|
const archivePath = path.join(directory, `${prefix}-private.json`)
|
||
|
|
const report = { started_at: new Date().toISOString(), prefix, checks: [], actions: [], fixtures: [], cleanup: [], archive_path: archivePath, passed: false }
|
||
|
|
const archive = { prefix, snapshots: [], submitted_materials: [], errors: [], usage: [] }
|
||
|
|
const fixtures = report.fixtures
|
||
|
|
let token, stage = 'preflight'
|
||
|
|
fs.mkdirSync(directory, { recursive: true })
|
||
|
|
// An existing report prevents accidental replays of paid production calls.
|
||
|
|
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2), { flag: 'wx', mode: 0o600 })
|
||
|
|
const persist = () => fs.writeFileSync(reportPath, JSON.stringify(report, null, 2), { mode: 0o600 })
|
||
|
|
const saveArchive = () => fs.writeFileSync(archivePath, JSON.stringify(archive, null, 2), { mode: 0o600 })
|
||
|
|
const check = (name, passed) => {
|
||
|
|
report.checks.push({ name, passed: !!passed })
|
||
|
|
persist()
|
||
|
|
assert.ok(passed, name)
|
||
|
|
}
|
||
|
|
const sql = statement => {
|
||
|
|
try {
|
||
|
|
return execFileSync('docker', ['--context', 'colima-yawan', 'exec', 'WeKnora-postgres', 'psql', '-U', 'postgres', '-d', 'yawan_native_acceptance_20260915', '-v', 'ON_ERROR_STOP=1', '-Atc', statement], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 15000, maxBuffer: 16 * 1024 * 1024 }).trim()
|
||
|
|
} catch {
|
||
|
|
throw new Error('Read-only evidence query failed')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
async function api(route, method = 'GET', body, expected = 200, timeout = 15000) {
|
||
|
|
const multipart = body instanceof FormData
|
||
|
|
let response
|
||
|
|
try {
|
||
|
|
response = await fetch(base + route, { method, headers: { ...(multipart ? {} : { 'Content-Type': 'application/json' }), ...(token ? { Authorization: `Bearer ${token}`, 'X-Tenant-ID': String(tenant) } : {}) }, body: body === undefined ? undefined : multipart ? body : JSON.stringify(body), signal: AbortSignal.timeout(timeout) })
|
||
|
|
} catch {
|
||
|
|
throw new Error(`${method} ${route}: request failed or timed out; no automatic retry`)
|
||
|
|
}
|
||
|
|
const text = await response.text()
|
||
|
|
let result
|
||
|
|
try { result = JSON.parse(text) } catch { result = null }
|
||
|
|
if (response.status !== expected || result === null) {
|
||
|
|
// Raw provider errors can contain private inputs. Keep them in the private archive only.
|
||
|
|
archive.errors.push({ stage, method, route, status: response.status, body: result || text })
|
||
|
|
throw new Error(`${method} ${route}: HTTP ${response.status}`)
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
function register(kind, value, dependsOn = []) {
|
||
|
|
const fixture = { id: value.id, kind, name: value.name, depends_on: dependsOn, jobs: [], archived: false, deleted: false, uncertain: false }
|
||
|
|
fixtures.push(fixture)
|
||
|
|
archive.snapshots.push({ kind, id: value.id, at: new Date().toISOString(), data: value })
|
||
|
|
persist()
|
||
|
|
return fixture
|
||
|
|
}
|
||
|
|
async function create(kind, route, body, dependsOn = []) {
|
||
|
|
stage = `create_${kind}`
|
||
|
|
report.pending_mutation = { kind, name: body.name, route }
|
||
|
|
persist()
|
||
|
|
const value = (await api(route, 'POST', body, 201, 30000)).data
|
||
|
|
const fixture = register(kind, value, dependsOn)
|
||
|
|
delete report.pending_mutation
|
||
|
|
persist()
|
||
|
|
return { value, fixture }
|
||
|
|
}
|
||
|
|
async function poll(fixture, initial, route, success) {
|
||
|
|
const started = Date.now()
|
||
|
|
const action = { fixture_id: fixture.id, job_id: initial.id, action: stage, status: initial.status || 'queued', elapsed_ms: 0 }
|
||
|
|
fixture.jobs.push(action)
|
||
|
|
report.actions.push(action)
|
||
|
|
persist()
|
||
|
|
let lastProgress = ''
|
||
|
|
while (Date.now() - started < jobLimitMS) {
|
||
|
|
const remaining = jobLimitMS - (Date.now() - started)
|
||
|
|
const current = (await api(route, 'GET', undefined, 200, Math.min(15000, remaining))).data
|
||
|
|
action.status = current.status
|
||
|
|
action.elapsed_ms = Date.now() - started
|
||
|
|
action.progress = current.progress
|
||
|
|
archive.snapshots.push({ kind: 'job', id: initial.id, at: new Date().toISOString(), data: current })
|
||
|
|
const progress = `${current.status}:${current.stage}:${current.progress}`
|
||
|
|
if (lastProgress !== progress) {
|
||
|
|
console.log(JSON.stringify({ action: action.action, job_id: initial.id, status: current.status, stage: current.stage, progress: current.progress, elapsed_seconds: Math.round(action.elapsed_ms / 1000) }))
|
||
|
|
lastProgress = progress
|
||
|
|
}
|
||
|
|
persist()
|
||
|
|
if (current.status === success) return current
|
||
|
|
if (['failed', 'cancelled'].includes(current.status)) throw new Error(`${stage}: job ${current.status}; inspect private archive`)
|
||
|
|
await sleep(Math.min(1500, Math.max(0, jobLimitMS - (Date.now() - started))))
|
||
|
|
}
|
||
|
|
throw new Error(`${stage}: six-minute verification deadline; running fixtures retained`)
|
||
|
|
}
|
||
|
|
async function submit(fixture, route, body, jobRoute, success) {
|
||
|
|
fixture.uncertain = true
|
||
|
|
persist()
|
||
|
|
const job = (await api(route, 'POST', body, 202, 30000)).data
|
||
|
|
fixture.uncertain = false
|
||
|
|
return poll(fixture, job, jobRoute(job), success)
|
||
|
|
}
|
||
|
|
function humanVersions(id) {
|
||
|
|
return JSON.parse(sql(`SELECT coalesce(json_agg(json_build_object('id',id,'number',number,'status',status,'persona',persona,'sources',sources) ORDER BY number), '[]'::json) FROM digital_human_versions WHERE tenant_id=${tenant} AND digital_human_id=${literal(id)}`))
|
||
|
|
}
|
||
|
|
async function distill(fixture, human, sources, modelID) {
|
||
|
|
assert.ok(sources.length <= 99)
|
||
|
|
assert.ok(sources.reduce((total, source) => total + [...source.text].length, 0) < 12000, 'One complete material batch per distillation')
|
||
|
|
archive.submitted_materials.push({ human_id: human.id, sources })
|
||
|
|
saveArchive()
|
||
|
|
const form = new FormData()
|
||
|
|
form.append('model_id', modelID)
|
||
|
|
form.append('revision', String(human.revision))
|
||
|
|
form.append('advice', 'Analyze only the supplied neutral consumer-education writing samples. Keep all required fields concise, cite original evidence, and preserve previously supported traits when adding the new sample. State insufficient evidence explicitly. Do not invent biography or prices.')
|
||
|
|
for (const source of sources) form.append('files', new Blob([source.text], { type: 'text/plain' }), source.name)
|
||
|
|
const job = await submit(fixture, `/digital-humans/${human.id}/distill`, form, value => `/digital-humans/${human.id}/jobs/${value.id}`, 'completed')
|
||
|
|
const batches = JSON.parse(sql(`SELECT json_build_object('completed_batches',checkpoint->>'completed_batches','total_batches',checkpoint->>'total_batches') FROM digital_human_jobs WHERE tenant_id=${tenant} AND digital_human_id=${literal(human.id)} AND id=${literal(job.id)}`))
|
||
|
|
check(`${stage}_one_full_batch`, Number(batches.completed_batches) === 1 && Number(batches.total_batches) === 1)
|
||
|
|
return (await api(`/digital-humans/${human.id}`)).data
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const initialMaterials = materials()
|
||
|
|
check('99_unique_files_in_single_batch', initialMaterials.length === 99 && new Set(initialMaterials.map(source => sha256(source.text))).size === 99 && initialMaterials.reduce((sum, source) => sum + [...source.text].length, 0) < 12000)
|
||
|
|
check('read_only_database_access', sql('SELECT 1') === '1')
|
||
|
|
stage = 'login'
|
||
|
|
const credentials = JSON.parse(fs.readFileSync(path.join(directory, 'super-admin-login.json'), 'utf8'))
|
||
|
|
const auth = await api('/auth/login', 'POST', { email: credentials.email, password: credentials.password })
|
||
|
|
token = auth.token
|
||
|
|
check('authenticated', typeof token === 'string' && token.length > 0)
|
||
|
|
const models = (await api('/models')).data
|
||
|
|
const eligible = models.filter(model => model.type === 'KnowledgeQA' && model.status === 'active')
|
||
|
|
const model = process.env.CURRENT_GENERATION_MODEL_ID ? eligible.find(value => value.id === process.env.CURRENT_GENERATION_MODEL_ID) : eligible.find(value => value.name === 'deepseek-flash') || eligible[0]
|
||
|
|
check('active_chat_model_available', !!model)
|
||
|
|
report.model = { id: model.id, name: model.name }
|
||
|
|
const kb = (await api('/knowledge-bases')).data.find(value => value.name === '销售逻辑')
|
||
|
|
check('readable_sales_logic_kb_available', !!kb)
|
||
|
|
report.knowledge_base_id = kb.id
|
||
|
|
const agent = await create('agent', '/agents', { name: `${prefix}-writer`, config: { role: 'production', model_id: model.id, agent_mode: 'quick-answer', kb_selection_mode: 'selected', knowledge_bases: [kb.id], temperature: 0.2, max_completion_tokens: 1024, system_prompt: 'You teach ordinary consumer communication: clarify needs, set a budget, and make rational purchase decisions. Follow the current requested JSON schema exactly. Use three distinct consecutive short outline segments. Do not output a manuscript or adult sexual material.' } })
|
||
|
|
const flow = await create('flow', '/workbench/resources/flows', { name: `${prefix}-flow`, data: { nodes: [{ id: 'writer', label: '课程作者', agent_id: agent.value.id }], edges: [] } }, [agent.value.id])
|
||
|
|
const task = await create('task', '/workbench/resources/content-tasks', { name: `${prefix}-task`, data: { flow_id: flow.value.id, source_text: '理性消费沟通:第一部分,澄清购物需求与具体使用场景;第二部分,承接需求确定预算与比较条件;第三部分,用前两部分的条件完成购买决策并复述理由。', reading_mode: 'quick', duration_minutes: 5 } }, [flow.value.id])
|
||
|
|
stage = 'decompose'
|
||
|
|
const taskJob = await submit(task.fixture, `/workbench/resources/content-tasks/${task.value.id}/actions/decompose`, { version: task.value.version }, value => `/workbench/jobs/${value.id}`, 'succeeded')
|
||
|
|
const decomposed = (await api(`/workbench/resources/content-tasks/${task.value.id}`)).data
|
||
|
|
archive.snapshots.push({ kind: 'task', id: decomposed.id, data: decomposed })
|
||
|
|
const segments = decomposed.data.segments || []
|
||
|
|
check('decomposition_saved_segments', segments.length > 0)
|
||
|
|
check('all_segments_use_selected_agent_and_node', segments.every(segment => segment.agent_id === agent.value.id && segment.node_id === 'writer'))
|
||
|
|
check('quick_retrieval_returned_evidence', decomposed.data.reading?.mode === 'quick' && decomposed.data.reading?.evidence_chunks > 0)
|
||
|
|
const categories = new Set((decomposed.data.agent_category_options?.[agent.value.id] || []).map(category => category.id))
|
||
|
|
check('category_recommendations_within_selected_agent', segments.every(segment => !segment.recommended_category_id || categories.has(segment.recommended_category_id)))
|
||
|
|
archive.usage = JSON.parse(sql(`SELECT coalesce(json_agg(json_build_object('id',id,'job_id',job_id,'status',status,'tokens',input_tokens+output_tokens,'points',points,'metadata',metadata)), '[]'::json) FROM workbench_usage WHERE tenant_id=${tenant} AND job_id=${literal(taskJob.id)}`))
|
||
|
|
check('one_metered_decomposition_call', archive.usage.length === 1 && archive.usage[0].status === 'succeeded')
|
||
|
|
report.decomposition = { segments: segments.length, job_id: taskJob.id, reading: decomposed.data.reading, metered_calls: archive.usage.length }
|
||
|
|
const created = await create('human', '/digital-humans', { name: `${prefix}-human`, description: 'Disposable verification of 99 complete sources and one incremental source.', model_id: model.id })
|
||
|
|
const primaryAgentID = created.value.primary_agent_id
|
||
|
|
check('human_primary_agent_created', !!primaryAgentID)
|
||
|
|
created.fixture.primary_agent_id = primaryAgentID
|
||
|
|
stage = 'distill_99_files'
|
||
|
|
let human = await distill(created.fixture, created.value, initialMaterials, model.id)
|
||
|
|
const firstVersions = humanVersions(human.id)
|
||
|
|
const first = versionWithSources(firstVersions, human.latest_version, initialMaterials)
|
||
|
|
const firstHash = sha256(JSON.stringify(first))
|
||
|
|
check('first_distillation_99_sources_saved', first.sources.length === 99)
|
||
|
|
check('first_version_remains_unpublished', human.active_version === 0 && first.status === 'draft')
|
||
|
|
archive.snapshots.push({ kind: 'human_after_99', id: human.id, data: human, versions: firstVersions })
|
||
|
|
saveArchive()
|
||
|
|
stage = 'distill_one_additional_file'
|
||
|
|
const added = materials(100, 1)
|
||
|
|
human = await distill(created.fixture, human, added, model.id)
|
||
|
|
const nextVersions = humanVersions(human.id)
|
||
|
|
const latest = versionWithSources(nextVersions, human.latest_version, [...initialMaterials, ...added])
|
||
|
|
check('continuous_distillation_preserves_previous_version', sha256(JSON.stringify(nextVersions.find(value => value.number === first.number))) === firstHash)
|
||
|
|
check('continuous_distillation_saves_100_sources', latest.number === first.number + 1 && latest.sources.length === 100)
|
||
|
|
check('new_version_remains_unpublished', human.active_version === 0 && latest.status === 'draft')
|
||
|
|
archive.snapshots.push({ kind: 'human_after_100', id: human.id, data: human, versions: nextVersions })
|
||
|
|
report.distillation = { human_id: human.id, versions: nextVersions.map(version => ({ number: version.number, source_count: (version.sources || []).length, status: version.status })), source_hashes: sourceSummary(latest.sources), successful_jobs: created.fixture.jobs.length }
|
||
|
|
saveArchive()
|
||
|
|
report.passed = true
|
||
|
|
} catch (error) {
|
||
|
|
report.failure = { stage, name: error.name, message: error.message }
|
||
|
|
archive.errors.push({ stage, name: error.name, message: error.message })
|
||
|
|
process.exitCode = 1
|
||
|
|
} finally {
|
||
|
|
stage = 'archive_and_cleanup'
|
||
|
|
// Archive full fixture state before any delete. A failed archive preserves that fixture.
|
||
|
|
for (const fixture of fixtures) {
|
||
|
|
const route = fixture.kind === 'agent' ? `/agents/${fixture.id}` : fixture.kind === 'human' ? `/digital-humans/${fixture.id}` : `/workbench/resources/${fixture.kind === 'task' ? 'content-tasks' : 'flows'}/${fixture.id}`
|
||
|
|
fixture.route = route
|
||
|
|
try {
|
||
|
|
const detail = (await api(route)).data
|
||
|
|
const versions = fixture.kind === 'human' ? humanVersions(fixture.id) : undefined
|
||
|
|
if (fixture.kind === 'human' && detail.processing_job_id) fixture.uncertain = true
|
||
|
|
archive.snapshots.push({ kind: fixture.kind, id: fixture.id, at: new Date().toISOString(), data: detail, versions })
|
||
|
|
saveArchive()
|
||
|
|
fixture.archived = true
|
||
|
|
} catch {
|
||
|
|
report.cleanup.push({ id: fixture.id, status: 'retained', reason: 'archive_failed' })
|
||
|
|
}
|
||
|
|
persist()
|
||
|
|
}
|
||
|
|
try { saveArchive() } catch { report.archive_failed = true }
|
||
|
|
for (const fixture of [...fixtures].reverse()) {
|
||
|
|
const blocked = report.archive_failed ? 'archive_failed' : cleanupBlocker(fixture, fixtures)
|
||
|
|
if (blocked) {
|
||
|
|
report.cleanup.push({ id: fixture.id, status: 'retained', reason: blocked })
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
await api(fixture.route, 'DELETE')
|
||
|
|
fixture.deleted = true
|
||
|
|
report.cleanup.push({ id: fixture.id, status: 'deleted' })
|
||
|
|
if (fixture.kind === 'human' && fixture.primary_agent_id) {
|
||
|
|
const remaining = Number(sql(`SELECT count(*) FROM custom_agents WHERE tenant_id=${tenant} AND id=${literal(fixture.primary_agent_id)} AND deleted_at IS NULL`))
|
||
|
|
check('human_primary_agent_removed_with_human', remaining === 0)
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
report.cleanup.push({ id: fixture.id, status: 'retained_or_unverified', reason: 'delete_request_failed' })
|
||
|
|
}
|
||
|
|
persist()
|
||
|
|
}
|
||
|
|
report.remaining = fixtures.filter(fixture => !fixture.deleted).map(fixture => ({ id: fixture.id, kind: fixture.kind, jobs: fixture.jobs }))
|
||
|
|
report.finished_at = new Date().toISOString()
|
||
|
|
report.passed = report.passed && !report.archive_failed && !report.pending_mutation && report.remaining.length === 0 && report.checks.every(value => value.passed) && !report.cleanup.some(value => value.status === 'retained_or_unverified')
|
||
|
|
if (!report.passed) process.exitCode = 1
|
||
|
|
persist()
|
||
|
|
console.log(JSON.stringify({ passed: report.passed, checks_passed: report.checks.filter(value => value.passed).length, checks_total: report.checks.length, remaining_fixture_count: report.remaining.length, report_path: reportPath }))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = { materials, sourceSummary, versionWithSources, cleanupBlocker }
|
||
|
|
if (require.main === module) main().catch(error => { console.error(JSON.stringify({ passed: false, error: error.code === 'EEXIST' ? 'Existing verification report prevents replay' : 'Verification setup or finalization failed; inspect private evidence' })); process.exitCode = 1 })
|