mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-12 03:02:21 +02:00
266 lines
18 KiB
JavaScript
266 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
|
/** Real 7011 list -> referential multi-delete replay using only synthetic notes. */
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { chromium } from 'playwright';
|
|
import {AMBIGUOUS_CASES,expectedNoteTitles,compareNoteState} from './note_test_oracle.mjs';
|
|
|
|
const root = path.resolve(new URL('..', import.meta.url).pathname);
|
|
const base = process.env.BASE_URL || 'http://127.0.0.1:7011';
|
|
const routingMode = process.env.ROUTING_MODE || 'baseline';
|
|
const followupCase = process.env.FOLLOWUP_CASE || 'original';
|
|
const plainTitles = process.env.TITLE_STYLE === 'plain';
|
|
const auditedFlow=process.env.AUDITED_FLOW==='true';
|
|
const followups = {
|
|
duplicate_titles: 'Delete the hf_fixture ones from that list.',
|
|
original: 'delete japan today and groceries from that list',
|
|
quoted: 'Delete the three notes named "Japan", "Today", and "Groceries" from that list.',
|
|
reversed: 'delete groceries japan and today from that list',
|
|
all_three: 'Delete all three notes from that list.',
|
|
negative: 'Do not delete any of those notes. Just tell me their titles.',
|
|
typo: 'plz delte japan today n groceries frm that list',
|
|
subset: 'Delete Japan and Groceries from that list; keep Today.',
|
|
keep_all: 'Keep all three notes. Do not change or delete anything.',
|
|
contrast: 'Do not delete Japan or Today. Delete only Groceries.',
|
|
drinks: 'remove milk tea and coffee from that list',
|
|
schedule_words: 'remove work tomorrow and weekend from that list',
|
|
explicit_ids: 'Delete all three listed notes using their exact IDs.',
|
|
quoted_typo: 'plz delte "Japan", "Today", and "Groceries" frm those notes',
|
|
single: 'Remove only the note titled Today. Leave the other two alone.',
|
|
except_one: 'Delete the notes in that list except Japan.',
|
|
punctuated: 'Remove these notes: Japan; Today; Groceries.',
|
|
neutral: 'Delete the notes titled "Harbor", "Orchid", and "Lantern" from that list.',
|
|
neutral_typo: 'plz delte the notes "Harbor", "Orchid", and "Lantern" frm that list',
|
|
user_punctuation: 'remove the groceries , japan , today note',
|
|
};
|
|
if (!Object.hasOwn(followups, followupCase)) throw Error('Unknown followup case');
|
|
const fixtureTitles = followupCase === 'drinks' ? ['Milk','Tea','Coffee']
|
|
: followupCase === 'duplicate_titles' ? ['hf_fixture', 'hf_fixture', 'Keep']
|
|
: followupCase === 'schedule_words' ? ['Tomorrow','Work','Weekend']
|
|
: ['neutral','neutral_typo'].includes(followupCase) ? ['Harbor','Orchid','Lantern']
|
|
: ['Groceries','Japan','Today'];
|
|
const expectedTitles = expectedNoteTitles(followupCase,fixtureTitles);
|
|
const reportPath = path.resolve(process.env.REPORT_PATH || path.join(root, `reports/multi-note-delete-followup-${new Date().toISOString().replace(/[:.]/g, '-')}.json`));
|
|
if (!reportPath.startsWith(path.join(root, 'reports') + path.sep) || fs.existsSync(reportPath)) throw Error('Report path must be new and under reports/');
|
|
const auth = JSON.parse(fs.readFileSync('/home/pewds/odysseus-cookbook-fresh/data/sessions.json', 'utf8'));
|
|
const token = Object.entries(auth).find(([, value]) => value?.username === 'sft_alex_creator')?.[0];
|
|
if (!token) throw Error('Dedicated SFT account has no active session');
|
|
const marker = `ody-multinote-${crypto.randomUUID()}`;
|
|
const report = { marker, routing_mode: routingMode, followup_case: followupCase, title_style: plainTitles ? 'plain' : 'prefixed', status: 'running', turns: [], cleanup: {}, privacy: 'Synthetic note details and sanitized model reply when explicitly audited.' };
|
|
const save = () => { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n'); };
|
|
save();
|
|
const parseSSE = body => body.replace(/\r\n/g, '\n').split('\n\n').flatMap(frame => {
|
|
const raw = frame.split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n');
|
|
if (!raw || raw === '[DONE]') return [];
|
|
try { return [JSON.parse(raw)]; } catch { return [{ type: 'invalid_sse' }]; }
|
|
});
|
|
|
|
let browser, context, page, session = '';
|
|
const noteIds = [];
|
|
const snapshotNotes=async()=>{
|
|
const responses=await Promise.all((auditedFlow?['false','true']:['false']).map(archived=>
|
|
context.request.get(`${base}/api/notes?archived=${archived}`)));
|
|
if(responses.some(r=>!r.ok())) throw Error('Cannot snapshot complete note state');
|
|
const rows=(await Promise.all(responses.map(r=>r.json()))).flatMap(r=>r.notes || []);
|
|
if(new Set(rows.map(r=>r.id)).size!==rows.length) throw Error('Inconsistent active/archived snapshot');
|
|
return rows;
|
|
};
|
|
try {
|
|
browser = await chromium.launch({ headless: true, args: ['--no-proxy-server'] });
|
|
context = await browser.newContext({ serviceWorkers: 'block', extraHTTPHeaders: {
|
|
'Accept-Encoding': 'identity', 'x-odysseus-routing-experiment': routingMode,
|
|
} });
|
|
await context.addCookies([{ name: 'odysseus_session', value: token, url: base }]);
|
|
const created = await context.request.post(`${base}/api/session`, { multipart: {
|
|
name: `[multi-note-followup] ${marker}`, model: 'odysseus-qwen3.5-tools-pre-heretic',
|
|
endpoint_id: process.env.ENDPOINT_ID || '1d1022ef',
|
|
endpoint_url: process.env.ENDPOINT_URL || 'http://100.67.207.85:19184/v1/chat/completions',
|
|
skip_validation: 'true', rag: 'false',
|
|
}});
|
|
if (!created.ok()) throw Error(`Session create HTTP ${created.status()}`);
|
|
session = (await created.json()).id;
|
|
if (plainTitles) {
|
|
const snapshot = await context.request.get(`${base}/api/notes`);
|
|
if (!snapshot.ok()) throw Error('PRECONDITION: cannot check title collisions');
|
|
if (((await snapshot.json()).notes || []).some(n => fixtureTitles.map(t => t.toLowerCase()).includes(String(n.title || '').trim().toLowerCase())))
|
|
throw Error('PRECONDITION: plain fixture title already exists; no fixtures created');
|
|
}
|
|
for (const suffix of fixtureTitles) {
|
|
const response = await context.request.post(`${base}/api/notes`, { data: {
|
|
title: plainTitles ? suffix : `${marker} ${suffix}`, content: `Synthetic ${suffix} note for ${marker}`,
|
|
label: 'ody-multinote-fixture',
|
|
note_type: 'note', source: 'eval', session_id: session,
|
|
}});
|
|
if (!response.ok()) throw Error(`Note create HTTP ${response.status()}`);
|
|
noteIds.push((await response.json()).id);
|
|
}
|
|
page = await context.newPage();
|
|
await page.goto(`${base}/#${session}`, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
await page.waitForFunction(id => window.__odysseusSessionReadyId === id, session, { timeout: 30000 });
|
|
const agent = page.locator('#mode-agent-btn');
|
|
if (await agent.getAttribute('aria-pressed') !== 'true') await agent.click();
|
|
const send = async prompt => {
|
|
const pending = page.waitForResponse(r => new URL(r.url()).pathname === '/api/chat_stream' && r.request().method() === 'POST', { timeout: 120000 });
|
|
await page.locator('textarea#message:visible').fill(prompt);
|
|
await page.locator('textarea#message:visible').press('Enter');
|
|
const response = await pending;
|
|
const events = parseSSE(await response.text());
|
|
const contract = events.find(event => event.type === 'turn_contract') || {};
|
|
const starts = events.filter(event => event.type === 'tool_start').map(event => ({ tool: event.tool, command: event.command || '' }));
|
|
const outputs = events.filter(event => event.type === 'tool_output').map(event => ({ tool: event.tool, ok: !event.error && (event.exit_code == null || event.exit_code === 0), command: event.command || '' }));
|
|
return { response, events, contract, starts, outputs };
|
|
};
|
|
const calendar = await send('List my next three calendar events.');
|
|
report.turns.push({name: 'calendar', checks: {
|
|
correct_mode: calendar.contract.routing_experiment === routingMode,
|
|
executed: calendar.outputs.some(x => x.tool === 'manage_calendar' && x.ok),
|
|
}});
|
|
const beforeRows = await snapshotNotes();
|
|
const untouchedIds = beforeRows.filter(n => !noteIds.includes(n.id)).map(n => n.id);
|
|
const unrelatedUnchanged=rows=>compareNoteState(beforeRows.filter(n=>untouchedIds.includes(n.id)),
|
|
rows.filter(n=>!noteIds.includes(n.id))).unchanged;
|
|
const listed = await send(`List my notes containing ${marker}. Return all three titles.`);
|
|
const listedText = listed.events.filter(e => e.type === 'tool_output').map(e => String(e.output || '')).join('\n');
|
|
const listedMetrics=listed.events.findLast(e=>e.type==='metrics') || {};
|
|
const listedSaved=(listedMetrics.data || listedMetrics).clean_v3_turn || [];
|
|
const listedEvidence=listedSaved.find(m=>m.role==='tool' && noteIds.every(id=>String(m.content).includes(id)));
|
|
if(listedEvidence) report.prior_note_evidence={call_id:listedEvidence.tool_call_id,
|
|
content_sha256:crypto.createHash('sha256').update(JSON.stringify(listedEvidence.content)).digest('hex'),
|
|
content_chars:String(listedEvidence.content).length};
|
|
if (!noteIds.every(id => listedText.includes(id))) {
|
|
throw Error('PRECONDITION: list did not return all three synthetic IDs; deletion replay skipped');
|
|
}
|
|
report.turns.push({
|
|
name: 'list', tools: listed.starts.map(item => item.tool),
|
|
checks: {
|
|
http_ok: listed.response.ok(), clean_route: listed.contract.selection_mode === 'clean_compact_v3_preview',
|
|
notes_capability: (listed.contract.active_capabilities || []).includes('notes'),
|
|
listed: listed.outputs.some(item => item.tool === 'manage_notes' && item.ok),
|
|
no_stream_error: !listed.events.some(event => ['error', 'invalid_sse'].includes(event.type)),
|
|
},
|
|
});
|
|
const removed = await send(followups[followupCase]);
|
|
const deleteCalls = removed.outputs.filter(item => item.tool === 'manage_notes' && item.ok && /"action"\s*:\s*"delete"/i.test(item.command));
|
|
const remaining = await snapshotNotes();
|
|
report.turns.push({
|
|
name: 'delete-followup', tools: removed.starts.map(item => item.tool), delete_calls: deleteCalls.length,
|
|
checks: {
|
|
http_ok: removed.response.ok(), clean_route: removed.contract.selection_mode === 'clean_compact_v3_preview',
|
|
notes_offered: (removed.contract.offered || []).includes('manage_notes'),
|
|
exact_requested_targets: fixtureTitles.every((title,index) =>
|
|
expectedTitles.includes(title) === !remaining.some(note => note.id === noteIds[index])),
|
|
unrelated_notes_preserved: unrelatedUnchanged(remaining),
|
|
no_stream_error: !removed.events.some(event => ['error', 'invalid_sse'].includes(event.type)),
|
|
},
|
|
});
|
|
report.turns[report.turns.length - 1].offered = removed.contract.offered;
|
|
report.turns[report.turns.length - 1].remaining_fixture_titles = remaining
|
|
.filter(note => noteIds.includes(note.id)).map(note => note.title);
|
|
report.outcome = {
|
|
deleted_fixtures: noteIds.filter(id => !remaining.some(note => note.id === id)).length,
|
|
unrelated_preserved: unrelatedUnchanged(remaining),
|
|
expected_deleted: expectedTitles.length,
|
|
exact_requested_targets: fixtureTitles.every((title,index) =>
|
|
expectedTitles.includes(title) === !remaining.some(note => note.id === noteIds[index])),
|
|
};
|
|
report.outcome.passed = report.outcome.deleted_fixtures === report.outcome.expected_deleted
|
|
&& report.outcome.unrelated_preserved && report.outcome.exact_requested_targets;
|
|
// Passive diagnostics only: no prompts, routing, or scoring changes.
|
|
const removalMetrics = removed.events.findLast(e => e.type === 'metrics') || {};
|
|
const metricData = removalMetrics.data || removalMetrics;
|
|
const savedTurn = metricData.clean_v3_turn || [];
|
|
report.diagnostics = {
|
|
agent_rounds: metricData.agent_rounds,
|
|
input_tokens: metricData.input_tokens,
|
|
injected_tokens: metricData.injected_tokens,
|
|
response_time: metricData.response_time,
|
|
ttft: metricData.time_to_first_token,
|
|
model_messages: savedTurn.filter(m => m.role === 'assistant').map(m => ({
|
|
tool_calls: (m.tool_calls || []).length,
|
|
mentions: fixtureTitles.filter(s => String(m.content || '').toLowerCase().includes(s.toLowerCase())),
|
|
})),
|
|
terminal_events: removed.events.map(e => e.type).filter(t =>
|
|
['rounds_exhausted','budget_exceeded','loop_breaker_triggered','completion_recovery'].includes(t)),
|
|
};
|
|
if (process.env.AUDIT_FINAL === 'true') {
|
|
report.diagnostics.sanitized_final = String(savedTurn.filter(m => m.role === 'assistant').at(-1)?.content || '')
|
|
.replaceAll(marker, '[fixture]').replace(/[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}/gi, '[id]').slice(0, 700);
|
|
}
|
|
report.turns[report.turns.length - 1].policy_decisions =
|
|
removalMetrics.data?.policy_decisions || removalMetrics.policy_decisions || [];
|
|
report.turns[report.turns.length - 1].proposals = removed.events
|
|
.filter(e => e.type === 'model_tool_proposal').map(e => {
|
|
let args; try { args = JSON.parse(e.function?.arguments || '{}'); } catch { args = {}; }
|
|
return {tool: e.function?.name, round: e.round, action: args.action,
|
|
target_suffix: beforeRows.find(n => noteIds.includes(n.id) &&
|
|
(n.id === (args.id || args.uid) || n.title?.toLowerCase() === String(args.title || '').toLowerCase()))?.title?.replace(marker, '').trim() || null,
|
|
argument_keys: Object.keys(args), target_is_fixture: noteIds.includes(args.id || args.uid)};
|
|
});
|
|
report.turns[report.turns.length - 1].errors = removed.events
|
|
.filter(e => e.type === 'tool_output' && e.error)
|
|
.map(e => ({tool: e.tool, argument_keys: Object.keys(JSON.parse(e.command || '{}')),
|
|
category: /not offered|not permitted/.test(String(e.output)) ? 'not_offered' : 'validation_or_execution'}));
|
|
if(auditedFlow) {
|
|
const wantedIds=noteIds.filter((id,i)=>expectedTitles.includes(fixtureTitles[i]));
|
|
const initialState=compareNoteState(beforeRows,remaining,wantedIds);
|
|
const summarizeTurn=turn=>{
|
|
const metric=turn.events.findLast(e=>e.type==='metrics') || {};
|
|
const data=metric.data || metric;
|
|
const final=String((data.clean_v3_turn || []).filter(m=>m.role==='assistant').at(-1)?.content || '');
|
|
const deleteTargets=turn.starts.filter(c=>c.tool==='manage_notes').flatMap(c=>{
|
|
let args;try {args=JSON.parse(c.command);} catch {return [];}
|
|
if(!['delete','remove'].includes(args.action)) return [];
|
|
const id=String(args.id || args.note_id || args.noteId || '').trim();
|
|
const records=beforeRows.filter(n=>noteIds.includes(n.id));
|
|
const target=(id && records.find(n=>n.id.startsWith(id))) || records.find(n=>
|
|
n.title.toLowerCase()===String(args.title || args.query || args.text || '').trim().toLowerCase());
|
|
return [target?.title || '[unresolved]'];
|
|
});
|
|
return {final:final.replaceAll(marker,'[fixture]').replace(/[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}/gi,'[id]').slice(0,1500),
|
|
model_rounds:data.agent_rounds,seconds:data.response_time,
|
|
attempted_delete_targets:deleteTargets,
|
|
duplicate_resolved_targets:deleteTargets.filter((t,i)=>t!=='[unresolved]' && deleteTargets.indexOf(t)!==i).length,
|
|
call_count:turn.starts.length,tool_error_count:turn.outputs.filter(o=>!o.ok).length,
|
|
clean_completion:turn.response.ok() && turn.events.some(e=>e.type==='metrics') &&
|
|
!turn.events.some(e=>['error','invalid_sse','rounds_exhausted','budget_exceeded'].includes(e.type))};
|
|
};
|
|
report.audited={rubric:'note-flow-v2',kind:AMBIGUOUS_CASES.has(followupCase)?'ambiguous':'explicit_or_control',
|
|
state_scope:'active_and_archived',
|
|
initial_state:initialState,initial_no_changes:compareNoteState(beforeRows,remaining).unchanged,
|
|
initial_response:summarizeTurn(removed),clarification_sent:false,
|
|
semantic_review:'pending_human_review_not_regex_scored'};
|
|
if(!report.outcome.unrelated_preserved || initialState.modified_count || initialState.added_count)
|
|
throw Error('Unexpected state change: stop before any clarification');
|
|
if(AMBIGUOUS_CASES.has(followupCase) && !initialState.exact) {
|
|
const prompt=`I mean the separate notes titled ${fixtureTitles.map(t=>JSON.stringify(t)).join(', ')}. Delete any of those still present from that list; leave all other notes unchanged.`;
|
|
const clarified=await send(prompt);
|
|
const afterRows=await snapshotNotes();
|
|
report.audited.clarification_sent=true;
|
|
report.audited.clarified_response=summarizeTurn(clarified);
|
|
report.audited.final_state=compareNoteState(beforeRows,afterRows,wantedIds);
|
|
report.outcome.unrelated_preserved=unrelatedUnchanged(afterRows);
|
|
if(!report.outcome.unrelated_preserved || report.audited.final_state.modified_count || report.audited.final_state.added_count)
|
|
throw Error('Unexpected state change after clarification');
|
|
} else report.audited.final_state=initialState;
|
|
}
|
|
for (const turn of report.turns) turn.status = Object.values(turn.checks).every(Boolean) ? 'passed' : 'failed';
|
|
report.status = report.turns.every(turn => turn.status === 'passed') ? 'passed' : 'failed';
|
|
if(auditedFlow) {report.initial_checks_status=report.status;report.status='measured_pending_semantic_review';}
|
|
} catch (error) {
|
|
report.status = 'failed'; report.error = String(error).split('\n')[0].slice(0, 400);
|
|
} finally {
|
|
if (page) await page.close();
|
|
if (context) {
|
|
for (const id of noteIds) {
|
|
const response = await context.request.delete(`${base}/api/notes/${encodeURIComponent(id)}`);
|
|
if (response.ok() || response.status() === 404) report.cleanup[id] = true;
|
|
}
|
|
if (session) report.cleanup.session = (await context.request.delete(`${base}/api/session/${encodeURIComponent(session)}`)).ok();
|
|
}
|
|
if (browser) await browser.close();
|
|
save();
|
|
}
|
|
console.log(JSON.stringify({ report: path.relative(root, reportPath), status: report.status, outcome: report.outcome, turns: report.turns.map(turn => ({ name: turn.name, status: turn.status, checks: turn.checks })) }));
|
|
if (!['passed','measured_pending_semantic_review'].includes(report.status)) process.exitCode = 1;
|