Harden Scattermind rank feedback bridge
This commit is contained in:
+1
-1
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"start": "node server.js",
|
||||
"check": "node --check server.js && node --check scripts/setup-appwrite.mjs && node --check public/app.js",
|
||||
"check": "node --check server.js && node --check scripts/setup-appwrite.mjs && node --check scripts/check-rank-feedback.mjs && node --check public/app.js && node scripts/check-rank-feedback.mjs",
|
||||
"setup:appwrite": "node scripts/setup-appwrite.mjs",
|
||||
"smoke": "node scripts/smoke.mjs"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const port = 43045 + Math.floor(Math.random() * 1000);
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(process.execPath, ['server.js'], {
|
||||
cwd: new URL('..', import.meta.url),
|
||||
env: { ...process.env, PORT: String(port), APPWRITE_ENDPOINT: '', APPWRITE_PROJECT_ID: '', APPWRITE_API_KEY: '' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let output = '';
|
||||
server.stdout.on('data', chunk => { output += chunk; });
|
||||
server.stderr.on('data', chunk => { output += chunk; });
|
||||
|
||||
async function waitForServer() {
|
||||
const deadline = Date.now() + 6000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`${base}/api/rank-feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ optionsText: '- one\n- two' }),
|
||||
});
|
||||
if (response.status < 500) return;
|
||||
} catch {
|
||||
await new Promise(resolve => setTimeout(resolve, 120));
|
||||
}
|
||||
}
|
||||
throw new Error(`server did not become ready:\n${output}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForServer();
|
||||
const response = await fetch(`${base}/api/rank-feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
schema: 'prioritix-feature-set-v1',
|
||||
sourceName: 'Scattermind',
|
||||
artifactId: 'snapshot_123',
|
||||
snapshotTitle: 'Tiny shop idea clarity pass',
|
||||
idea: 'Scattermind clarified a small product idea. Ranker must defend the next build order, not create a dashboard.',
|
||||
context: 'Solo builder. Need a rank-ready build order after Snapshot / Concept Map. Avoid accounts, workspaces, and team voting.',
|
||||
mode: 'mvp',
|
||||
featureSet: {
|
||||
features: [
|
||||
{ id: 'bridge-contract', title: 'Snapshot to Ranker feature-set contract', description: 'Convert Concept Map next moves into a rank-ready feature set with provenance.' },
|
||||
{ id: 'build-order-preview', title: 'Build order preview', description: 'Show do first, validate next, defer, and park with reasons.' },
|
||||
{ id: 'workspace', title: 'Accounts and saved workspaces', description: 'Full dashboard with auth, workspace collaboration, team voting, and sync.' },
|
||||
{ id: 'billing', title: 'Subscription billing layer', description: 'Pricing, checkout, invoices, account plans, and admin controls.' },
|
||||
{ id: 'export', title: 'Exportable decision brief', description: 'Simple brief for sharing the defended build order.' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const data = await response.json();
|
||||
assert.equal(data.ok, true);
|
||||
assert.equal(data.input.provenance.artifactId, 'snapshot_123');
|
||||
assert.equal(data.input.provenance.source, 'Scattermind');
|
||||
assert.equal(data.ranked.length, 5);
|
||||
assert.deepEqual(Object.keys(data.buildOrder), ['doFirst', 'validateNext', 'defer', 'park']);
|
||||
assert.equal(data.ranked[0].id, data.buildOrder.doFirst[0]);
|
||||
assert.notEqual(data.ranked[0].id, 'workspace', 'dashboard swamp must not win the bridge fixture');
|
||||
assert.ok(['defer', 'park'].includes(data.ranked.find(item => item.id === 'workspace').lane.id));
|
||||
assert.ok(['defer', 'park'].includes(data.ranked.find(item => item.id === 'billing').lane.id));
|
||||
assert.match(data.brief.summary, /nearest follow-up|strongest signal/i);
|
||||
console.log(JSON.stringify({ ok: true, top: data.ranked[0].id, provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2));
|
||||
} finally {
|
||||
server.kill('SIGTERM');
|
||||
}
|
||||
@@ -333,7 +333,7 @@ const judgementModes = {
|
||||
const wordSets = {
|
||||
revenue: ['pay', 'paid', 'price', 'pricing', 'stripe', 'checkout', 'invoice', 'sales', 'sell', 'buyer', 'subscription', 'revenue', 'client', 'customer', 'conversion', 'upsell'],
|
||||
value: ['pain', 'problem', 'save', 'faster', 'clear', 'clarity', 'decision', 'feedback', 'user', 'customer', 'relief', 'manual', 'core', 'must', 'need', 'trust'],
|
||||
effort: ['dashboard', 'workspace', 'workspaces', 'collaboration', 'realtime', 'integration', 'integrations', 'automation', 'accounts', 'auth', 'billing', 'ai', 'model', 'mobile', 'sync', 'admin', 'export', 'exportable', 'slack', 'notion', 'team', 'voting'],
|
||||
effort: ['dashboard', 'workspace', 'workspaces', 'collaboration', 'realtime', 'integration', 'integrations', 'automation', 'accounts', 'auth', 'billing', 'subscription', 'pricing', 'checkout', 'invoices', 'ai', 'model', 'mobile', 'sync', 'admin', 'export', 'exportable', 'slack', 'notion', 'team', 'voting'],
|
||||
risk: ['unclear', 'maybe', 'complex', 'hard', 'risky', 'unknown', 'depends', 'enterprise', 'platform', 'everything', 'all users', 'team voting', 'marketplace', 'saved workspaces', 'team voting'],
|
||||
novelty: ['new', 'novel', 'different', 'unique', 'original', 'weird', 'unexpected', 'expert', 'judgement', 'reflection', 'rank', 'compare'],
|
||||
urgency: ['now', 'launch', 'blocker', 'first', 'mvp', 'today', 'week', 'urgent', 'stuck', 'before', 'next'],
|
||||
@@ -358,15 +358,52 @@ function parseOptionsFromText(value) {
|
||||
}).filter(item => item.title);
|
||||
}
|
||||
|
||||
function cleanProvenance(input = {}) {
|
||||
const artifact = input.artifact && typeof input.artifact === 'object' ? input.artifact : {};
|
||||
const source = input.source && typeof input.source === 'object' ? input.source : {};
|
||||
return {
|
||||
schema: cleanText(input.schema || artifact.schema || input.type || 'prioritix-feature-set-v1', 80),
|
||||
source: cleanText(input.sourceName || source.name || artifact.sourceName || 'Scattermind', 80),
|
||||
artifactId: cleanText(input.artifactId || input.sourceArtifactId || artifact.id || source.artifactId || '', 120),
|
||||
snapshotTitle: cleanText(input.snapshotTitle || artifact.snapshotTitle || input.ideaTitle || '', 160),
|
||||
conceptMapId: cleanText(input.conceptMapId || artifact.conceptMapId || '', 120),
|
||||
};
|
||||
}
|
||||
|
||||
function optionsFromBody(body = {}) {
|
||||
const featureSet = body.featureSet && typeof body.featureSet === 'object' ? body.featureSet : {};
|
||||
const rawFeatures = Array.isArray(body.features) ? body.features : Array.isArray(featureSet.features) ? featureSet.features : null;
|
||||
if (rawFeatures) {
|
||||
return rawFeatures.slice(0, 24).map((item, index) => ({
|
||||
id: cleanText(item?.id || item?.key || `feature-${index + 1}`, 80) || `feature-${index + 1}`,
|
||||
title: cleanText(item?.title || item?.name || item?.action || '', 140),
|
||||
description: cleanText(item?.description || item?.brief || item?.why || item?.evidenceNeeded || '', 520),
|
||||
provenance: {
|
||||
sourceId: cleanText(item?.sourceId || item?.sourceArtifactId || item?.id || '', 120),
|
||||
sourceSection: cleanText(item?.sourceSection || item?.section || item?.lane || '', 80),
|
||||
},
|
||||
})).filter(item => item.title);
|
||||
}
|
||||
if (Array.isArray(body.options)) {
|
||||
return body.options.slice(0, 24).map((item, index) => ({
|
||||
id: cleanText(item?.id || `option-${index + 1}`, 80) || `option-${index + 1}`,
|
||||
title: cleanText(item?.title || item?.name || '', 140),
|
||||
description: cleanText(item?.description || item?.brief || '', 420),
|
||||
})).filter(item => item.title);
|
||||
}
|
||||
return parseOptionsFromText(body.optionsText || featureSet.optionsText || '');
|
||||
}
|
||||
|
||||
function scoreOption(option, mode, context = '') {
|
||||
const text = `${option.title} ${option.description} ${context}`;
|
||||
const effortHits = hits(text, wordSets.effort);
|
||||
const riskHits = hits(text, wordSets.risk);
|
||||
const coreLoopHits = hits(text, ['ranked feedback map', 'feedback map', 'pasted feature', 'feature lists', 'first-pass', 'decision brief', 'expert reflections']);
|
||||
const bridgeHits = hits(text, ['snapshot', 'concept map', 'feature set', 'build order', 'rank-ready', 'provenance', 'next moves']);
|
||||
const metrics = {
|
||||
value: Math.min(10, 4.8 + hits(text, wordSets.value) * 0.9 + coreLoopHits * 0.8 + hits(context, wordSets.value) * 0.15),
|
||||
feasibility: Math.max(1, Math.min(10, 8.2 - effortHits * 0.8 - riskHits * 0.35 + Math.min(1.1, coreLoopHits * 0.28))),
|
||||
confidence: Math.max(1, Math.min(10, 5.8 + coreLoopHits * 0.35 + hits(text, ['manual', 'existing', 'already', 'simple', 'clear', 'known']) * 0.7 - hits(text, ['maybe', 'unknown', 'new market', 'all users']) * 0.9)),
|
||||
value: Math.min(10, 4.8 + hits(text, wordSets.value) * 0.9 + coreLoopHits * 0.8 + bridgeHits * 0.75 + hits(context, wordSets.value) * 0.15),
|
||||
feasibility: Math.max(1, Math.min(10, 8.2 - effortHits * 0.8 - riskHits * 0.35 + Math.min(1.1, coreLoopHits * 0.28 + bridgeHits * 0.18))),
|
||||
confidence: Math.max(1, Math.min(10, 5.8 + coreLoopHits * 0.35 + bridgeHits * 0.28 + hits(text, ['manual', 'existing', 'already', 'simple', 'clear', 'known']) * 0.7 - hits(text, ['maybe', 'unknown', 'new market', 'all users']) * 0.9)),
|
||||
urgency: Math.min(10, 4.9 + hits(text, wordSets.urgency) * 0.9),
|
||||
revenue: Math.min(10, 3.8 + hits(text, wordSets.revenue) * 1.05),
|
||||
novelty: Math.min(10, 4.1 + hits(text, wordSets.novelty) * 0.95),
|
||||
@@ -388,6 +425,7 @@ function laneFor(option, rankIndex, total) {
|
||||
|
||||
function reasonFor(option) {
|
||||
const m = option.metrics;
|
||||
if (option.lane?.id === 'do' && /snapshot|concept map|feature set|build order|rank/i.test(`${option.title} ${option.description}`)) return 'it strengthens the Scattermind → Ranker bridge instead of inventing a generic workspace';
|
||||
if (m.feasibility >= 7.2 && m.value >= 6.2) return 'high enough value with low enough delivery drag to create fast signal';
|
||||
if (m.revenue >= 6.4) return 'clearer buyer or money signal than the rest of the list';
|
||||
if (m.risk >= 6.5) return 'interesting, but it carries assumption risk that should be tested before build';
|
||||
@@ -441,25 +479,33 @@ app.post('/api/rank-feedback', (req, res) => {
|
||||
const context = cleanMultiline(req.body?.context || '', 3000);
|
||||
const modeId = cleanText(req.body?.mode || 'progress', 40);
|
||||
const mode = judgementModes[modeId] || judgementModes.progress;
|
||||
let options = Array.isArray(req.body?.options)
|
||||
? req.body.options.slice(0, 24).map((item, index) => ({ id: `option-${index + 1}`, title: cleanText(item?.title || item?.name || '', 140), description: cleanText(item?.description || item?.brief || '', 420) })).filter(item => item.title)
|
||||
: parseOptionsFromText(req.body?.optionsText || '');
|
||||
const provenance = cleanProvenance(req.body || {});
|
||||
let options = optionsFromBody(req.body || {});
|
||||
if (options.length < 2) return res.status(400).json({ error: 'Paste at least two options, features, ideas, or next moves to rank.' });
|
||||
options = options.map(option => ({ ...option, metrics: scoreOption(option, mode, `${idea}\n${context}`) }))
|
||||
const bridgeContext = `${idea}\n${context}\n${provenance.snapshotTitle}\n${provenance.schema}`;
|
||||
options = options.map(option => ({ ...option, metrics: scoreOption(option, mode, bridgeContext) }))
|
||||
.sort((a, b) => b.metrics.score - a.metrics.score || b.metrics.value - a.metrics.value || a.metrics.risk - b.metrics.risk)
|
||||
.map((option, index, arr) => ({
|
||||
...option,
|
||||
rank: index + 1,
|
||||
lane: laneFor(option, index, arr.length),
|
||||
reason: reasonFor(option),
|
||||
concern: concernFor(option),
|
||||
}));
|
||||
.map((option, index, arr) => {
|
||||
const lane = laneFor(option, index, arr.length);
|
||||
const rankedOption = { ...option, rank: index + 1, lane };
|
||||
return {
|
||||
...rankedOption,
|
||||
reason: reasonFor(rankedOption),
|
||||
concern: concernFor(rankedOption),
|
||||
};
|
||||
});
|
||||
res.json({
|
||||
ok: true,
|
||||
mode: { id: modeId in judgementModes ? modeId : 'progress', label: mode.label },
|
||||
input: { idea, context, optionCount: options.length },
|
||||
input: { idea, context, optionCount: options.length, provenance },
|
||||
ranked: options,
|
||||
brief: createDecisionBrief({ idea, context, mode, ranked: options }),
|
||||
buildOrder: {
|
||||
doFirst: options.filter(item => item.lane.id === 'do').map(item => item.id),
|
||||
validateNext: options.filter(item => item.lane.id === 'test').map(item => item.id),
|
||||
defer: options.filter(item => item.lane.id === 'defer').map(item => item.id),
|
||||
park: options.filter(item => item.lane.id === 'park').map(item => item.id),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user