Reframe Ranker as feedback map MVP

This commit is contained in:
OpenClaw Bot
2026-05-26 22:06:03 +02:00
parent d204a76802
commit f75dbb1a10
4 changed files with 404 additions and 503 deletions
+161
View File
@@ -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 35 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 310 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) => {