Harden Scattermind rank feedback bridge
This commit is contained in:
@@ -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