diff --git a/package.json b/package.json index c8f29f1..e4bd34d 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/check-rank-feedback.mjs b/scripts/check-rank-feedback.mjs new file mode 100644 index 0000000..6cfa2a0 --- /dev/null +++ b/scripts/check-rank-feedback.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'); +} diff --git a/server.js b/server.js index f65d4f5..151ab8d 100644 --- a/server.js +++ b/server.js @@ -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), + }, }); });