Reframe Ranker as feedback map MVP
This commit is contained in:
@@ -302,6 +302,167 @@ app.post('/api/reorder', requireAgent, async (req, res) => {
|
||||
res.json({ changed });
|
||||
});
|
||||
|
||||
const judgementModes = {
|
||||
progress: {
|
||||
label: 'Fastest useful progress',
|
||||
weights: { value: 1.2, feasibility: 1.55, confidence: 1.25, urgency: 1.15, revenue: 0.55, novelty: 0.35, risk: -1.05 },
|
||||
next: 'Test the highest-ranked low-effort option manually before adding product machinery.',
|
||||
},
|
||||
mvp: {
|
||||
label: 'Best MVP order',
|
||||
weights: { value: 1.55, feasibility: 1.25, confidence: 1.15, urgency: 0.85, revenue: 0.75, novelty: 0.45, risk: -1.2 },
|
||||
next: 'Build the smallest slice that proves the core user promise, then defer everything that needs the proof first.',
|
||||
},
|
||||
revenue: {
|
||||
label: 'Revenue potential',
|
||||
weights: { value: 1.15, feasibility: 0.75, confidence: 0.95, urgency: 1.05, revenue: 1.85, novelty: 0.35, risk: -1.0 },
|
||||
next: 'Validate willingness to pay before polishing delivery. A paid manual version beats a beautiful unpaid feature.',
|
||||
},
|
||||
risk: {
|
||||
label: 'Risk reduction',
|
||||
weights: { value: 0.95, feasibility: 1.0, confidence: 1.45, urgency: 0.8, revenue: 0.45, novelty: 0.25, risk: -1.85 },
|
||||
next: 'Start where uncertainty is highest and evidence is cheapest. Do not build around an untested assumption.',
|
||||
},
|
||||
originality: {
|
||||
label: 'Most original / differentiated',
|
||||
weights: { value: 1.0, feasibility: 0.65, confidence: 0.75, urgency: 0.55, revenue: 0.65, novelty: 1.85, risk: -0.9 },
|
||||
next: 'Keep the unusual angle, but demand one proof step so originality does not become expensive weirdness.',
|
||||
},
|
||||
};
|
||||
|
||||
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'],
|
||||
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'],
|
||||
};
|
||||
|
||||
function hits(text, words) {
|
||||
const lower = ` ${String(text || '').toLowerCase()} `;
|
||||
return words.reduce((count, word) => count + (lower.includes(word) ? 1 : 0), 0);
|
||||
}
|
||||
|
||||
function parseOptionsFromText(value) {
|
||||
const text = cleanMultiline(value, 12000);
|
||||
const lines = text.split('\n').map(line => line.replace(/^\s*[-*•\d.)]+\s*/, '').trim()).filter(Boolean);
|
||||
const optionLines = lines.length >= 2 ? lines : text.split(/[;|]/).map(part => part.trim()).filter(Boolean);
|
||||
return optionLines.slice(0, 24).map((line, index) => {
|
||||
const [rawTitle, ...rest] = line.split(/\s[-–—:]\s/);
|
||||
return {
|
||||
id: `option-${index + 1}`,
|
||||
title: cleanText(rawTitle || line, 140),
|
||||
description: cleanText(rest.join(' — ') || line, 420),
|
||||
};
|
||||
}).filter(item => item.title);
|
||||
}
|
||||
|
||||
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 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)),
|
||||
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),
|
||||
risk: Math.min(10, 2.5 + riskHits * 1.1 + Math.max(0, effortHits - 2) * 0.45),
|
||||
};
|
||||
const weights = mode.weights;
|
||||
const weighted = Object.entries(weights).reduce((sum, [key, weight]) => sum + metrics[key] * weight, 0);
|
||||
const possible = Object.entries(weights).reduce((sum, [_key, weight]) => sum + Math.abs(weight) * 10, 0);
|
||||
const score = Math.max(0, Math.min(100, Math.round((weighted + Math.abs(Math.min(0, weights.risk || 0)) * 10) / possible * 100)));
|
||||
return { ...metrics, score };
|
||||
}
|
||||
|
||||
function laneFor(option, rankIndex, total) {
|
||||
if (rankIndex === 0) return { id: 'do', label: 'Do first', action: 'Build/test now' };
|
||||
if (rankIndex <= Math.max(1, Math.ceil(total * 0.32))) return { id: 'test', label: 'Validate next', action: 'Find evidence' };
|
||||
if (rankIndex >= Math.max(2, Math.floor(total * 0.72))) return { id: 'park', label: 'Park / cut', action: 'Keep out of the active plan' };
|
||||
return { id: 'defer', label: 'Defer', action: 'Sequence after proof' };
|
||||
}
|
||||
|
||||
function reasonFor(option) {
|
||||
const m = option.metrics;
|
||||
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';
|
||||
if (m.novelty >= 6.7) return 'more differentiated than the safe options, but still needs proof';
|
||||
return 'balanced tradeoff across value, effort, confidence, and timing';
|
||||
}
|
||||
|
||||
function concernFor(option) {
|
||||
const m = option.metrics;
|
||||
if (m.risk >= 6.5) return 'The hidden risk is pretending this is ready to build before the core assumption is proven.';
|
||||
if (m.feasibility <= 4.5) return 'The likely trap is scope creep: this may need too much machinery for an MVP.';
|
||||
if (m.confidence <= 4.5) return 'Evidence looks thin. Treat this as a question, not a roadmap item.';
|
||||
if (m.revenue <= 4 && m.value >= 6) return 'Useful does not automatically mean sellable. Check willingness to pay or repeat use.';
|
||||
return 'The main risk is sequencing: do it only if it supports the first useful proof.';
|
||||
}
|
||||
|
||||
function createDecisionBrief({ idea, context, mode, ranked }) {
|
||||
const top = ranked[0];
|
||||
const second = ranked[1];
|
||||
const risky = ranked.slice().sort((a, b) => b.metrics.risk - a.metrics.risk)[0];
|
||||
const deferred = ranked.filter(item => ['defer', 'park'].includes(item.lane.id)).slice(0, 3);
|
||||
const theme = top ? `The strongest signal is “${top.title}” because it has ${reasonFor(top)}.` : 'The list needs at least two options before ranking becomes useful.';
|
||||
return {
|
||||
headline: top ? `Start with ${top.title}` : 'Add options to get a ranked feedback map',
|
||||
summary: `${theme}${second ? ` “${second.title}” is the nearest follow-up, not a parallel first step.` : ''}`,
|
||||
expertReflections: [
|
||||
{
|
||||
lens: 'Product expert',
|
||||
text: top ? `The ranking says the first job is not to build more surface area; it is to prove the highest-signal option. ${mode.next}` : 'A product expert would ask for concrete alternatives before giving serious advice.',
|
||||
},
|
||||
{
|
||||
lens: 'Scattermind simplifier',
|
||||
text: deferred.length ? `Park ${deferred.map(item => `“${item.title}”`).join(', ')} for now. Not bad ideas — just mental tabs you do not need open while testing the first move.` : 'The list is already narrow. Keep it that way; do not add a fake backlog around a simple decision.',
|
||||
},
|
||||
{
|
||||
lens: 'Structured operator',
|
||||
text: risky ? `The decision quality depends on one assumption: ${risky.title}. What evidence would make this move up or down the list? Write that before committing resources.` : 'Expose the criteria, the confidence, and the thing that would change the decision. That is what makes this defensible.',
|
||||
},
|
||||
],
|
||||
next48Hours: top ? [
|
||||
`Write a one-paragraph test for “${top.title}”.`,
|
||||
'Put it in front of 3–5 real people or run it manually once.',
|
||||
`Do not touch ${deferred[0] ? `“${deferred[0].title}”` : 'the parked ideas'} until the first signal is real.`,
|
||||
] : ['Paste 3–10 options.', 'Choose what the ranking should care about.', 'Run the first-pass judgement.'],
|
||||
caution: 'This is first-pass judgement, not an oracle. Change the criteria if the context changes.',
|
||||
};
|
||||
}
|
||||
|
||||
app.post('/api/rank-feedback', (req, res) => {
|
||||
const idea = cleanMultiline(req.body?.idea || '', 3000);
|
||||
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 || '');
|
||||
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}`) }))
|
||||
.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),
|
||||
}));
|
||||
res.json({
|
||||
ok: true,
|
||||
mode: { id: modeId in judgementModes ? modeId : 'progress', label: mode.label },
|
||||
input: { idea, context, optionCount: options.length },
|
||||
ranked: options,
|
||||
brief: createDecisionBrief({ idea, context, mode, ranked: options }),
|
||||
});
|
||||
});
|
||||
|
||||
app.get(/.*/, (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
|
||||
|
||||
app.use((error, _req, res, _next) => {
|
||||
|
||||
Reference in New Issue
Block a user