Strengthen rank feedback decision brief
This commit is contained in:
@@ -45,7 +45,7 @@ Ranker's continuation job is narrow:
|
||||
|
||||
`Snapshot / Concept Map → candidate feature/action set → Rank-ready build order`
|
||||
|
||||
`POST /api/rank-feedback` accepts a `prioritix-feature-set-v1`-style payload from Scattermind and returns ranked items plus `buildOrder.doFirst / validateNext / defer / park`. It accepts candidate arrays as `features`, `actions`, `nextMoves`, or `candidates` either at the top level or under `featureSet`, and it can consume a nested Concept Map directly, so Scattermind can hand off `conceptMap.nextActions / nextMoves` without renaming them into fake software features. Sectioned Concept Maps may also include `validateNext`, `deferred`, and `parkingLot`; Ranker combines those sections into one build-order pass while preserving `sourceSection` and treating deferred/parked sections as lane hints. Empty wrapper arrays are ignored rather than allowed to shadow a real nested Concept Map action set, and non-empty normalized wrappers are merged with Concept Map validation/deferred/parking sections rather than dropping that context. That keeps partially-normalized Scattermind exports rankable without losing the source lane contract. It also returns a `brief` with source, next-48-hour actions, carried-forward assumptions/constraints/non-goals, and `whatWouldChangeRanking` checks, plus a `handoff` object (`rank-feedback-result-v1`) with source provenance, item trace rows, and contract warnings for missing artifact IDs, source sections, original prompt provenance, or evidence on active items. If Scattermind sends the current paid Concept Map shape as lenses rather than arrays, Ranker can parse `conceptMap.lenses.channel.content` / `buildOrder` labels (`Build first`, `Test manually`, `Defer`, `Probably noise`) into rank-ready candidates and can read guardrails from the risk lens. Keep this contract action-first; do not use it as a reason to add generic dashboard, auth, billing, or workspace layers before the bridge has proof.
|
||||
`POST /api/rank-feedback` accepts a `prioritix-feature-set-v1`-style payload from Scattermind and returns ranked items plus `buildOrder.doFirst / validateNext / defer / park`. It accepts candidate arrays as `features`, `actions`, `nextMoves`, or `candidates` either at the top level or under `featureSet`, and it can consume a nested Concept Map directly, so Scattermind can hand off `conceptMap.nextActions / nextMoves` without renaming them into fake software features. Sectioned Concept Maps may also include `validateNext`, `deferred`, and `parkingLot`; Ranker combines those sections into one build-order pass while preserving `sourceSection` and treating deferred/parked sections as lane hints. Empty wrapper arrays are ignored rather than allowed to shadow a real nested Concept Map action set, and non-empty normalized wrappers are merged with Concept Map validation/deferred/parking sections rather than dropping that context. That keeps partially-normalized Scattermind exports rankable without losing the source lane contract. It also returns a `brief` with source, next-48-hour actions, carried-forward assumptions/constraints/non-goals, and `whatWouldChangeRanking` checks, lane-level `buildOrderDetails` with each item's reason, next step, evidence question, source section, lane source, score, and confidence, plus a `handoff` object (`rank-feedback-result-v1`) with source provenance, item trace rows, and contract warnings for missing artifact IDs, source sections, original prompt provenance, or evidence on active items. If Scattermind sends the current paid Concept Map shape as lenses rather than arrays, Ranker can parse `conceptMap.lenses.channel.content` / `buildOrder` labels (`Build first`, `Test manually`, `Defer`, `Probably noise`) into rank-ready candidates and can read guardrails from the risk lens. Keep this contract action-first; do not use it as a reason to add generic dashboard, auth, billing, or workspace layers before the bridge has proof.
|
||||
|
||||
Candidate items may include optional 1–10 `rankerHints` (`value`, `effort`, `confidence`, `urgency`, `revenue`, `novelty`, `risk`). Ranker blends those explicit Scattermind hints with text heuristics; `effort` is inverted into feasibility. Hints improve the defended order, but `recommendedLane: "defer"` or `"park"` remains a safety rail so dashboard-swamp items do not get promoted by flashy wording. For clean bridge handoff, Scattermind should send `sourceSection` and `evidenceNeeded` on each active next move. Scattermind can also send `targetAudience`, `constraints`, `assumptions`, and `nonGoals` / `avoid` at the top level, in `featureSet`, inside `conceptMap.context`, or as a structured top-level `context` object with `summary`, `targetAudience`, `constraints`, `nonGoals` / `avoid`, and `assumptions`; Ranker turns that structured context into readable scoring text instead of leaking `[object Object]`. If Scattermind only has a flat context string, Ranker now extracts simple guardrails such as `Solo builder`, `Manual proof`, `Avoid ...`, `No ...`, and `Do not ...` into `input.decisionContext` / `handoff.decisionContext` so early bridge exports still protect against dashboard/auth/billing drift. Ranker returns that decision context in `input.decisionContext` and `handoff.decisionContext`, and penalizes candidates that conflict with source non-goals (for example saved workspaces/auth/billing before the continuation proof). If Scattermind sends duplicate candidate IDs, Ranker keeps the first ID, suffixes later duplicates (`preview-2`), and reports the normalization in `handoff.warnings` / `handoff.itemTrace` so downstream build-order references remain addressable.
|
||||
|
||||
|
||||
+133
-22
@@ -1,5 +1,5 @@
|
||||
const sample = {
|
||||
idea: 'Scattermind clarified a messy course idea. Now I need the first build order, not a dashboard.',
|
||||
idea: 'Scattermind clarified a messy course idea. Now I need feedback on the feature/functionality order, not a dashboard.',
|
||||
optionsText: `- Manual build-order preview from one Concept Map
|
||||
- Copyable decision brief with Do first / Validate next / Defer / Park
|
||||
- Evidence questions beside each next move
|
||||
@@ -11,9 +11,17 @@ const sample = {
|
||||
mode: 'mvp',
|
||||
};
|
||||
|
||||
const laneMeta = {
|
||||
do: { title: 'Do first', note: 'The active first move. Give it oxygen before the backlog starts breeding.' },
|
||||
test: { title: 'Validate next', note: 'Strong candidates, but they need evidence before build time.' },
|
||||
defer: { title: 'Defer', note: 'Useful later. Dangerous if it steals attention now.' },
|
||||
park: { title: 'Park / cut', note: 'Not in the active decision. Reopen only if new evidence earns it.' },
|
||||
};
|
||||
|
||||
const form = document.querySelector('#rankForm');
|
||||
const results = document.querySelector('#results');
|
||||
const toastEl = document.querySelector('#toast');
|
||||
let lastResult = null;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
|
||||
@@ -43,7 +51,7 @@ function laneClass(lane) {
|
||||
return `lane-${lane?.id || 'defer'}`;
|
||||
}
|
||||
|
||||
function renderMetrics(metrics) {
|
||||
function renderMetrics(metrics = {}) {
|
||||
const items = [
|
||||
['Value', metrics.value],
|
||||
['Feasible', metrics.feasibility],
|
||||
@@ -56,45 +64,147 @@ function renderMetrics(metrics) {
|
||||
`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderPills(items = []) {
|
||||
if (!items.length) return '';
|
||||
return `<div class="signal-pills">${items.map((item) => `<span>${escapeHtml(item)}</span>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderRankCard(item) {
|
||||
return `
|
||||
<article class="rank-card ${laneClass(item.lane)}">
|
||||
<div class="rank-topline">
|
||||
<span class="rank-number">#${item.rank}</span>
|
||||
<span class="lane-pill">${escapeHtml(item.lane?.label || 'Ranked')}</span>
|
||||
<strong>${item.metrics.score}</strong>
|
||||
</div>
|
||||
<h3>${escapeHtml(item.title)}</h3>
|
||||
${item.description && item.description !== item.title ? `<p class="item-description">${escapeHtml(item.description)}</p>` : ''}
|
||||
${renderPills(item.scoreDrivers || [])}
|
||||
<p><b>Why:</b> ${escapeHtml(item.reason)}.</p>
|
||||
<p><b>Concern:</b> ${escapeHtml(item.concern)}</p>
|
||||
<div class="action-strip">
|
||||
<div><span>Next step</span><p>${escapeHtml(item.nextStep || 'Run the smallest proof step.')}</p></div>
|
||||
<div><span>Evidence question</span><p>${escapeHtml(item.evidenceQuestion || 'What proof would change this ranking?')}</p></div>
|
||||
<div><span>Success / kill signal</span><p>${escapeHtml(item.successSignal || '')} ${escapeHtml(item.killSignal ? `Kill if: ${item.killSignal}` : '')}</p></div>
|
||||
</div>
|
||||
${renderMetrics(item.metrics)}
|
||||
${renderPills(item.scoringNotes || [])}
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLane(ranked, laneId) {
|
||||
const items = ranked.filter((item) => item.lane?.id === laneId);
|
||||
const meta = laneMeta[laneId];
|
||||
return `
|
||||
<section class="lane-column ${items.length ? '' : 'empty-lane'}">
|
||||
<div class="lane-head">
|
||||
<span>${escapeHtml(meta.title)}</span>
|
||||
<b>${items.length}</b>
|
||||
</div>
|
||||
<p>${escapeHtml(meta.note)}</p>
|
||||
<div class="lane-stack">
|
||||
${items.length ? items.map(renderRankCard).join('') : '<em>No items landed here.</em>'}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function markdownBrief(data) {
|
||||
const brief = data.brief || {};
|
||||
const glance = brief.quickGlance || {};
|
||||
const ranked = data.ranked || [];
|
||||
const lanes = ['do', 'test', 'defer', 'park'];
|
||||
const laneTitles = { do: 'Do first', test: 'Validate next', defer: 'Defer', park: 'Park / cut' };
|
||||
return [
|
||||
`# ${brief.headline || 'Ranked feedback map'}`,
|
||||
'',
|
||||
brief.summary || '',
|
||||
'',
|
||||
'## Quick judgement',
|
||||
`- Top pick: ${glance.topPick || 'n/a'}`,
|
||||
`- Why it wins: ${glance.whyThisWins || 'n/a'}`,
|
||||
`- Next action: ${glance.nextAction || 'n/a'}`,
|
||||
`- Evidence question: ${glance.evidenceQuestion || 'n/a'}`,
|
||||
`- Biggest trap: ${glance.biggestTrap || 'n/a'}`,
|
||||
`- Confidence: ${data.rankConfidence?.level || 'n/a'} — ${data.rankConfidence?.reason || ''}`,
|
||||
'',
|
||||
...lanes.flatMap((lane) => {
|
||||
const items = ranked.filter((item) => item.lane?.id === lane);
|
||||
return [`## ${laneTitles[lane]}`, ...(items.length ? items.map((item) => `- #${item.rank} ${item.title}: ${item.reason}. Next: ${item.nextStep}`) : ['- None']) , ''];
|
||||
}),
|
||||
'## Next 48 hours',
|
||||
...(brief.next48Hours || []).map((step) => `- ${step}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function copyText(text, label) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast(`${label} copied.`);
|
||||
} catch {
|
||||
toast('Clipboard blocked. Browser being a tiny tyrant.');
|
||||
}
|
||||
}
|
||||
|
||||
function attachResultActions(data) {
|
||||
document.querySelector('#copyBrief')?.addEventListener('click', () => copyText(markdownBrief(data), 'Decision brief'));
|
||||
document.querySelector('#copyActions')?.addEventListener('click', () => copyText((data.brief?.next48Hours || []).map((step, index) => `${index + 1}. ${step}`).join('\n'), '48h actions'));
|
||||
document.querySelector('#copyJson')?.addEventListener('click', () => copyText(JSON.stringify({ brief: data.brief, ranked: data.ranked, buildOrder: data.buildOrderDetails, handoff: data.handoff }, null, 2), 'JSON handoff'));
|
||||
}
|
||||
|
||||
function renderResults(data) {
|
||||
lastResult = data;
|
||||
const ranked = data.ranked || [];
|
||||
const brief = data.brief || {};
|
||||
const glance = brief.quickGlance || {};
|
||||
results.hidden = false;
|
||||
results.innerHTML = `
|
||||
<div class="results-head">
|
||||
<p class="eyebrow">${escapeHtml(data.mode?.label || 'Ranked feedback')}</p>
|
||||
<div class="results-head memo-head">
|
||||
<p class="eyebrow">${escapeHtml(data.mode?.label || 'Ranked feedback')} · first-pass judgement memo</p>
|
||||
<h2>${escapeHtml(brief.headline || 'Ranked feedback map')}</h2>
|
||||
<p>${escapeHtml(brief.summary || '')}</p>
|
||||
<div class="memo-stamps"><span>Not an oracle</span><span>${escapeHtml(data.rankConfidence?.level || 'first pass')} confidence</span><span>${ranked.length} items judged</span></div>
|
||||
</div>
|
||||
|
||||
<div class="ranked-list">
|
||||
${ranked.map((item) => `
|
||||
<article class="rank-card ${laneClass(item.lane)}">
|
||||
<div class="rank-topline">
|
||||
<span class="rank-number">#${item.rank}</span>
|
||||
<span class="lane-pill">${escapeHtml(item.lane?.label || 'Ranked')}</span>
|
||||
<strong>${item.metrics.score}</strong>
|
||||
</div>
|
||||
<h3>${escapeHtml(item.title)}</h3>
|
||||
${item.description && item.description !== item.title ? `<p class="item-description">${escapeHtml(item.description)}</p>` : ''}
|
||||
<p><b>Why:</b> ${escapeHtml(item.reason)}.</p>
|
||||
<p><b>Concern:</b> ${escapeHtml(item.concern)}</p>
|
||||
${renderMetrics(item.metrics)}
|
||||
</article>
|
||||
`).join('')}
|
||||
<section class="quick-glance" aria-label="Quick judgement">
|
||||
<div><span>Do this first</span><strong>${escapeHtml(glance.topPick || 'Add options')}</strong></div>
|
||||
<div><span>Why it wins</span><p>${escapeHtml(glance.whyThisWins || 'The list needs more comparable options.')}</p></div>
|
||||
<div><span>Proof to collect</span><p>${escapeHtml(glance.evidenceQuestion || 'Name the evidence that would change the ranking.')}</p></div>
|
||||
<div><span>Trap to avoid</span><p>${escapeHtml(glance.biggestTrap || brief.caution || 'Do not treat first-pass judgement as final truth.')}</p></div>
|
||||
</section>
|
||||
|
||||
<div class="result-actions" aria-label="Copy result actions">
|
||||
<button class="button ghost" type="button" id="copyBrief">Copy decision brief</button>
|
||||
<button class="button ghost" type="button" id="copyActions">Copy 48h actions</button>
|
||||
<button class="button ghost" type="button" id="copyJson">Copy JSON handoff</button>
|
||||
</div>
|
||||
|
||||
<div class="brief-grid">
|
||||
<div class="lane-board">
|
||||
${['do', 'test', 'defer', 'park'].map((lane) => renderLane(ranked, lane)).join('')}
|
||||
</div>
|
||||
|
||||
<div class="brief-grid reflection-room">
|
||||
<article class="brief-card next-card">
|
||||
<span>Next 48 hours</span>
|
||||
<ol>${(brief.next48Hours || []).map((step) => `<li>${escapeHtml(step)}</li>`).join('')}</ol>
|
||||
</article>
|
||||
<article class="brief-card next-card">
|
||||
<span>Rank confidence</span>
|
||||
<p><b>${escapeHtml(data.rankConfidence?.level || 'First pass')}</b> — ${escapeHtml(data.rankConfidence?.reason || brief.caution || '')}</p>
|
||||
</article>
|
||||
${(brief.whatWouldChangeRanking || []).length ? `
|
||||
<article class="brief-card next-card">
|
||||
<span>What would change the order</span>
|
||||
<ol>${brief.whatWouldChangeRanking.map((change) => `<li>${escapeHtml(change)}</li>`).join('')}</ol>
|
||||
</article>
|
||||
` : ''}
|
||||
${(data.closeCalls || []).length ? `
|
||||
<article class="brief-card next-card">
|
||||
<span>Close calls</span>
|
||||
<ol>${data.closeCalls.map((call) => `<li>${escapeHtml(call.note)}</li>`).join('')}</ol>
|
||||
</article>
|
||||
` : ''}
|
||||
${(brief.assumptions || []).length ? `
|
||||
<article class="brief-card">
|
||||
<span>Context carried forward</span>
|
||||
@@ -102,7 +212,7 @@ function renderResults(data) {
|
||||
</article>
|
||||
` : ''}
|
||||
${(brief.expertReflections || []).map((reflection) => `
|
||||
<article class="brief-card">
|
||||
<article class="brief-card expert-card">
|
||||
<span>${escapeHtml(reflection.lens)}</span>
|
||||
<p>${escapeHtml(reflection.text)}</p>
|
||||
</article>
|
||||
@@ -110,6 +220,7 @@ function renderResults(data) {
|
||||
</div>
|
||||
<p class="caution">${escapeHtml(brief.caution || 'First-pass judgement, not an oracle.')}</p>
|
||||
`;
|
||||
attachResultActions(data);
|
||||
results.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
@@ -118,7 +229,7 @@ async function createFeedbackMap(event) {
|
||||
const submit = form.querySelector('button[type="submit"]');
|
||||
const payload = Object.fromEntries(new FormData(form).entries());
|
||||
submit.disabled = true;
|
||||
submit.textContent = 'Ranking…';
|
||||
submit.textContent = 'Judging…';
|
||||
try {
|
||||
const response = await fetch('/api/rank-feedback', {
|
||||
method: 'POST',
|
||||
|
||||
+18
-16
@@ -3,10 +3,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#101626" />
|
||||
<meta name="rank-version" content="2.1.0-editorial-decision-room" />
|
||||
<title>Ranker — ranked feedback maps for messy decisions</title>
|
||||
<link rel="stylesheet" href="/styles.css?v=2.1.0-editorial-decision-room" />
|
||||
<meta name="theme-color" content="#f3eee4" />
|
||||
<meta name="rank-version" content="2.2.0-feedback-front-door" />
|
||||
<title>Ranker — feedback front door for messy decisions</title>
|
||||
<link rel="stylesheet" href="/styles.css?v=2.2.0-feedback-front-door" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="page-shell">
|
||||
@@ -22,9 +22,9 @@
|
||||
|
||||
<div class="hero-grid" id="top">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">Decision feedback for scatterminds, builders, and structured people</p>
|
||||
<h1 id="hero-title">Dump your options. See what deserves attention first.</h1>
|
||||
<p class="lede">Ranker turns a messy list of ideas, features, offers, roadmap items, or next moves into a ranked feedback map: what to test, build, defer, or drop — and why.</p>
|
||||
<p class="eyebrow">Feedback intake for ideas, features, offers, and next moves</p>
|
||||
<h1 id="hero-title">Submit the mess. Get a defended first move.</h1>
|
||||
<p class="lede">Ranker is the front door to useful feedback: paste an idea plus the features or possible moves around it, choose the judgement lens, and get a decision brief with reasons, risks, expert reflections, and next steps.</p>
|
||||
<div class="hero-actions">
|
||||
<a class="button primary" href="#try">Rank a messy list</a>
|
||||
<button class="button ghost" type="button" id="loadSampleTop">Load sample</button>
|
||||
@@ -57,19 +57,19 @@
|
||||
|
||||
<section class="decision-tool" id="try" aria-labelledby="try-title">
|
||||
<div class="tool-intro">
|
||||
<p class="eyebrow">MVP · no account · no dashboard swamp</p>
|
||||
<h2 id="try-title">Rank the pile</h2>
|
||||
<p>Paste one idea plus the possible features, directions, offers, or next steps. Pick what the ranking should care about. Ranker gives a first-pass decision brief.</p>
|
||||
<p class="eyebrow">MVP · feedback front door · no account · no dashboard swamp</p>
|
||||
<h2 id="try-title">Send a decision brief to the room</h2>
|
||||
<p>Describe what you want feedback on, then list the possible features, directions, offers, or next steps. Ranker gives you the quick judgement first, then the deeper reflections.</p>
|
||||
</div>
|
||||
|
||||
<form class="rank-form" id="rankForm">
|
||||
<label>
|
||||
<span>Main idea or context</span>
|
||||
<span>What do you want feedback on?</span>
|
||||
<textarea name="idea" rows="4" placeholder="Example: I’m building a tool that helps freelancers package their services and decide what to sell first."></textarea>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Options to rank <b>required</b></span>
|
||||
<span>Possible moves / features / functionality <b>required</b></span>
|
||||
<textarea name="optionsText" rows="9" required placeholder="One per line. Bullets, rambling, half-thoughts are fine.
|
||||
- Offer critique
|
||||
- Pricing calculator
|
||||
@@ -80,16 +80,18 @@
|
||||
</label>
|
||||
|
||||
<fieldset class="mode-picker">
|
||||
<legend>What should the ranking care about?</legend>
|
||||
<legend>What should the judgement optimize for?</legend>
|
||||
<label><input type="radio" name="mode" value="progress" checked /> <span>Fastest useful progress</span></label>
|
||||
<label><input type="radio" name="mode" value="mvp" /> <span>Best MVP order</span></label>
|
||||
<label><input type="radio" name="mode" value="validation" /> <span>Fastest validation</span></label>
|
||||
<label><input type="radio" name="mode" value="revenue" /> <span>Revenue potential</span></label>
|
||||
<label><input type="radio" name="mode" value="risk" /> <span>Risk reduction</span></label>
|
||||
<label><input type="radio" name="mode" value="risk" /> <span>Safest proof order</span></label>
|
||||
<label><input type="radio" name="mode" value="learning" /> <span>Biggest assumption test</span></label>
|
||||
<label><input type="radio" name="mode" value="originality" /> <span>Most original</span></label>
|
||||
</fieldset>
|
||||
|
||||
<label>
|
||||
<span>Extra constraints <em>optional</em></span>
|
||||
<span>Constraints the feedback should respect <em>optional</em></span>
|
||||
<input name="context" placeholder="Example: one week, solo builder, no paid ads, must be useful on mobile" />
|
||||
</label>
|
||||
|
||||
@@ -123,6 +125,6 @@
|
||||
</main>
|
||||
|
||||
<div class="toast" id="toast" hidden></div>
|
||||
<script src="/app.js?v=2.1.0-editorial-decision-room" type="module"></script>
|
||||
<script src="/app.js?v=2.2.0-feedback-front-door" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+14
-1
File diff suppressed because one or more lines are too long
@@ -65,6 +65,12 @@ try {
|
||||
assert.equal(data.ranked.length, 6);
|
||||
assert.deepEqual(Object.keys(data.buildOrder), ['doFirst', 'validateNext', 'defer', 'park']);
|
||||
assert.equal(data.ranked[0].id, data.buildOrder.doFirst[0]);
|
||||
assert.equal(data.buildOrderDetails.doFirst[0].id, data.buildOrder.doFirst[0]);
|
||||
assert.equal(data.buildOrderDetails.doFirst[0].sourceSection, 'concept-map.nextMoves');
|
||||
assert.equal(data.buildOrderDetails.doFirst[0].laneSource, 'ranked');
|
||||
assert.equal(typeof data.buildOrderDetails.doFirst[0].score, 'number');
|
||||
assert.equal(typeof data.buildOrderDetails.doFirst[0].confidence, 'number');
|
||||
assert.match(data.buildOrderDetails.doFirst[0].nextStep, /manual proof/i);
|
||||
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));
|
||||
|
||||
@@ -393,9 +393,19 @@ const judgementModes = {
|
||||
next: 'Validate willingness to pay before polishing delivery. A paid manual version beats a beautiful unpaid feature.',
|
||||
},
|
||||
risk: {
|
||||
label: 'Risk reduction',
|
||||
label: 'Safest proof order',
|
||||
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.',
|
||||
next: 'Start with the option that reduces risk without betting the roadmap on it.',
|
||||
},
|
||||
validation: {
|
||||
label: 'Fastest validation',
|
||||
weights: { value: 1.25, feasibility: 1.7, confidence: 1.35, urgency: 1.1, revenue: 0.35, novelty: 0.25, risk: -0.85 },
|
||||
next: 'Choose the option that can produce real user evidence fastest, even if the first version is manual.',
|
||||
},
|
||||
learning: {
|
||||
label: 'Biggest assumption test',
|
||||
weights: { value: 1.1, feasibility: 1.35, confidence: -0.35, urgency: 0.55, revenue: 0.45, novelty: 0.45, risk: 1.15 },
|
||||
next: 'Pick the riskiest important assumption that can be tested cheaply. The goal is learning, not shipping surface area.',
|
||||
},
|
||||
originality: {
|
||||
label: 'Most original / differentiated',
|
||||
@@ -423,11 +433,11 @@ function parseOptionsFromText(value) {
|
||||
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/);
|
||||
const [rawTitle, ...rest] = line.split(/\s*[-–—:]\s+/);
|
||||
return {
|
||||
id: `option-${index + 1}`,
|
||||
title: cleanText(rawTitle || line, 140),
|
||||
description: cleanText(rest.join(' — ') || line, 420),
|
||||
description: cleanText(rest.join(' — '), 420),
|
||||
};
|
||||
}).filter(item => item.title);
|
||||
}
|
||||
@@ -664,7 +674,7 @@ function scoreOption(option, mode, context = '', decisionContext = {}) {
|
||||
const normalizedLaneHint = normalizeLaneHint(laneHint);
|
||||
const conflicts = nonGoalConflicts(`${option.title} ${option.description}`, decisionContext);
|
||||
const nonGoalPenalty = Math.min(14, conflicts.length * 7);
|
||||
const laneBoost = /do|first|now|build/.test(laneHint) ? 0.55 : /validate|test|proof/.test(laneHint) ? 0.25 : /defer|park|cut/.test(laneHint) ? -0.75 : 0;
|
||||
const laneBoost = /do|first|now|build/.test(laneHint) ? 1.35 : /validate|test|proof/.test(laneHint) ? 0.35 : /defer|park|cut/.test(laneHint) ? -0.75 : 0;
|
||||
const lanePenalty = normalizedLaneHint === 'park' ? 18 : normalizedLaneHint === 'defer' ? 9 : 0;
|
||||
const heuristicMetrics = {
|
||||
value: Math.min(10, 4.8 + hits(text, wordSets.value) * 0.9 + coreLoopHits * 0.8 + bridgeHits * 0.75 + proofHits * 0.2 + hits(context, wordSets.value) * 0.15),
|
||||
@@ -719,14 +729,30 @@ function laneFor(option, rankIndex, total) {
|
||||
return { id: 'defer', label: 'Defer', action: 'Sequence after proof' };
|
||||
}
|
||||
|
||||
function scoreDriversFor(option) {
|
||||
const m = option.metrics || {};
|
||||
const drivers = [];
|
||||
if (m.nonGoalConflicts?.length) drivers.push('source guardrail conflict');
|
||||
if (m.value >= 7) drivers.push('strong user value');
|
||||
if (m.feasibility >= 7) drivers.push('low delivery drag');
|
||||
if (m.confidence >= 7) drivers.push('clear proof path');
|
||||
if (m.revenue >= 6.5) drivers.push('buyer signal');
|
||||
if (m.novelty >= 6.5) drivers.push('differentiated angle');
|
||||
if (option.factors?.evidenceNeeded) drivers.push('explicit evidence needed');
|
||||
if ((option.factors?.dependencies || []).length === 0) drivers.push('few dependencies');
|
||||
if (m.risk >= 6.5) drivers.push('high assumption risk');
|
||||
return drivers.slice(0, 4);
|
||||
}
|
||||
|
||||
function reasonFor(option) {
|
||||
const m = option.metrics;
|
||||
const drivers = scoreDriversFor(option).filter(driver => driver !== 'source guardrail conflict' && driver !== 'high assumption risk');
|
||||
if (m.nonGoalConflicts?.length) return `it conflicts with the source non-goal “${m.nonGoalConflicts[0]}”, so it should not lead the build order`;
|
||||
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 (drivers.length >= 2) return `it wins on ${drivers.join(', ')} while staying inside the current proof slice`;
|
||||
if (option.factors?.evidenceNeeded && m.confidence >= 6.4) return 'it names the evidence needed, so the next move can be tested instead of guessed';
|
||||
if (m.feasibility >= 7.2 && m.value >= 6.2) return 'it has high enough value with low enough delivery drag to create fast signal';
|
||||
if (m.revenue >= 6.4) return 'it has a clearer buyer or money signal than the rest of the list';
|
||||
if (m.risk >= 6.5) return 'it is interesting, but carries assumption risk that should be tested before build';
|
||||
if (m.risk >= 6.5) return 'it is important enough to investigate, but carries assumption risk that should be tested before build';
|
||||
if (m.novelty >= 6.7) return 'it is more differentiated than the safe options, but still needs proof';
|
||||
return 'it has the best balanced tradeoff across value, effort, confidence, and timing';
|
||||
}
|
||||
@@ -735,6 +761,7 @@ function concernFor(option) {
|
||||
const m = option.metrics;
|
||||
if (m.nonGoalConflicts?.length) return `Source context says not to do this yet: ${m.nonGoalConflicts.join('; ')}.`;
|
||||
if ((option.factors?.dependencies || []).length >= 3) return 'Too many prerequisites. Split the proof slice before treating this as build-ready.';
|
||||
if (option.factors?.risk) return `The explicit risk is: ${option.factors.risk}.`;
|
||||
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.';
|
||||
@@ -742,6 +769,51 @@ function concernFor(option) {
|
||||
return 'The main risk is sequencing: do it only if it supports the first useful proof.';
|
||||
}
|
||||
|
||||
function evidenceQuestionFor(option) {
|
||||
if (!option) return '';
|
||||
if (option.factors?.evidenceNeeded) return option.factors.evidenceNeeded;
|
||||
if (option.lane?.id === 'park') return `What would make “${option.title}” worth reopening later?`;
|
||||
if (option.metrics?.revenue >= 6.4) return `Will a real buyer ask for or pay for “${option.title}” before it is polished?`;
|
||||
if (option.metrics?.risk >= 6.2) return `What is the cheapest test that could disprove “${option.title}”?`;
|
||||
return `Can 3–5 target users understand and act on “${option.title}” without extra explanation?`;
|
||||
}
|
||||
|
||||
function nextStepFor(option) {
|
||||
if (!option) return '';
|
||||
if (option.lane?.id === 'do') return `Run one manual proof of “${option.title}” before building supporting machinery.`;
|
||||
if (option.lane?.id === 'test') return `Design the smallest evidence test for “${option.title}” and collect signal from real users.`;
|
||||
if (option.lane?.id === 'defer') return `Keep “${option.title}” sequenced after the active proof; do not parallel-build it.`;
|
||||
return `Park “${option.title}” unless new evidence changes the decision.`;
|
||||
}
|
||||
|
||||
function successSignalFor(option) {
|
||||
if (!option) return '';
|
||||
if (option.metrics?.revenue >= 6.4) return 'A real prospect asks for the outcome, accepts a price, or requests the next step.';
|
||||
if (option.lane?.id === 'do') return 'At least 2 of 3 real users can name why this should be first and what they would do next.';
|
||||
if (option.lane?.id === 'test') return 'The test produces a clear yes/no learning, not polite interest.';
|
||||
return 'New evidence makes this more urgent than the current active lane.';
|
||||
}
|
||||
|
||||
function killSignalFor(option) {
|
||||
if (!option) return '';
|
||||
if (option.metrics?.nonGoalConflicts?.length) return 'It still conflicts with the source guardrails after review.';
|
||||
if (option.metrics?.feasibility <= 4.5) return 'The proof slice needs platform work before any user signal exists.';
|
||||
return 'People understand the idea but do not take, request, or value the next step.';
|
||||
}
|
||||
|
||||
function scoringNotesFor(option) {
|
||||
const notes = [];
|
||||
const m = option.metrics || {};
|
||||
if (option.factors?.evidenceNeeded) notes.push('Boosted because it names evidence to collect.');
|
||||
if (m.nonGoalConflicts?.length) notes.push('Penalized because it conflicts with source guardrails.');
|
||||
if (m.feasibility >= 7) notes.push('Boosted for low delivery drag.');
|
||||
if (m.value >= 7) notes.push('Boosted for user value.');
|
||||
if (m.revenue >= 6.5) notes.push('Boosted for buyer signal.');
|
||||
if (m.risk >= 6.5) notes.push('Flagged as assumption-heavy.');
|
||||
if ((option.factors?.dependencies || []).length >= 2) notes.push('Penalized for dependencies.');
|
||||
return notes.slice(0, 4);
|
||||
}
|
||||
|
||||
function whatWouldChangeRanking(top, second, risky) {
|
||||
if (!top) return ['Add at least two concrete next moves with evidence needed.'];
|
||||
const changes = [];
|
||||
@@ -760,6 +832,15 @@ function createDecisionBrief({ idea, context, mode, ranked, provenance, decision
|
||||
const deferred = ranked.filter(item => ['defer', 'park'].includes(item.lane.id)).slice(0, 3);
|
||||
const sourceLabel = [provenance?.snapshotTitle, provenance?.artifactId].filter(Boolean).join(' · ');
|
||||
const theme = top ? `The strongest signal is “${top.title}” because ${reasonFor(top)}.` : 'The list needs at least two options before ranking becomes useful.';
|
||||
const quickGlance = top ? {
|
||||
topPick: top.title,
|
||||
topLane: top.lane?.label || '',
|
||||
whyThisWins: reasonFor(top),
|
||||
nextAction: nextStepFor(top),
|
||||
evidenceQuestion: evidenceQuestionFor(top),
|
||||
biggestTrap: concernFor(top),
|
||||
doNotBuildYet: deferred.slice(0, 2).map(item => item.title),
|
||||
} : null;
|
||||
const assumptions = [
|
||||
...(decisionContext?.assumptions || []),
|
||||
...(decisionContext?.constraints || []).map(item => `Constraint: ${item}`),
|
||||
@@ -768,6 +849,7 @@ function createDecisionBrief({ idea, context, mode, ranked, provenance, decision
|
||||
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.` : ''}${sourceLabel ? ` Source: ${sourceLabel}.` : ''}`,
|
||||
quickGlance,
|
||||
source: provenance ? {
|
||||
schema: provenance.schema,
|
||||
source: provenance.source,
|
||||
@@ -801,6 +883,45 @@ function createDecisionBrief({ idea, context, mode, ranked, provenance, decision
|
||||
};
|
||||
}
|
||||
|
||||
function rankConfidenceFor(ranked = []) {
|
||||
if (ranked.length < 2) return { level: 'low', reason: 'There are not enough comparable options for a confident ranking.' };
|
||||
const gap = Math.abs((ranked[0].metrics?.score || 0) - (ranked[1].metrics?.score || 0));
|
||||
if (gap <= 3) return { level: 'close call', reason: `The top two options are only ${gap} points apart, so treat the winner as a sequencing bet, not a law.` };
|
||||
if (gap <= 8) return { level: 'medium', reason: `The top option leads by ${gap} points, but the follow-up is still worth rechecking after one proof cycle.` };
|
||||
return { level: 'strong', reason: `The top option leads by ${gap} points and has the clearest first-proof profile.` };
|
||||
}
|
||||
|
||||
function closeCallsFor(ranked = []) {
|
||||
const calls = [];
|
||||
for (let index = 0; index < ranked.length - 1; index += 1) {
|
||||
const current = ranked[index];
|
||||
const next = ranked[index + 1];
|
||||
const gap = Math.abs((current.metrics?.score || 0) - (next.metrics?.score || 0));
|
||||
if (gap <= 5) calls.push({
|
||||
pair: [current.title, next.title],
|
||||
gap,
|
||||
note: `“${current.title}” barely beats “${next.title}”; rerank if new evidence changes effort, confidence, or buyer signal.`,
|
||||
});
|
||||
}
|
||||
return calls.slice(0, 3);
|
||||
}
|
||||
|
||||
function compactBuildItems(items = []) {
|
||||
return items.map(item => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
reason: item.reason,
|
||||
nextStep: item.nextStep,
|
||||
evidenceQuestion: item.evidenceQuestion,
|
||||
concern: item.concern,
|
||||
sourceSection: item.provenance?.sourceSection || '',
|
||||
sourceId: item.provenance?.sourceId || '',
|
||||
laneSource: item.lane?.source || 'ranked',
|
||||
score: item.metrics?.score ?? null,
|
||||
confidence: item.metrics?.confidence ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function createHandoffContract({ ranked, provenance, decisionContext }) {
|
||||
const warnings = [];
|
||||
if (!provenance?.artifactId) warnings.push('missing source artifact id');
|
||||
@@ -864,6 +985,12 @@ app.post('/api/rank-feedback', (req, res) => {
|
||||
...rankedOption,
|
||||
reason: reasonFor(rankedOption),
|
||||
concern: concernFor(rankedOption),
|
||||
nextStep: nextStepFor(rankedOption),
|
||||
evidenceQuestion: evidenceQuestionFor(rankedOption),
|
||||
successSignal: successSignalFor(rankedOption),
|
||||
killSignal: killSignalFor(rankedOption),
|
||||
scoreDrivers: scoreDriversFor(rankedOption),
|
||||
scoringNotes: scoringNotesFor(rankedOption),
|
||||
};
|
||||
});
|
||||
const brief = createDecisionBrief({ idea, context, mode, ranked: options, provenance, decisionContext });
|
||||
@@ -874,13 +1001,22 @@ app.post('/api/rank-feedback', (req, res) => {
|
||||
input: { idea, context, optionCount: options.length, provenance, decisionContext },
|
||||
ranked: options,
|
||||
brief,
|
||||
rankConfidence: rankConfidenceFor(options),
|
||||
closeCalls: closeCallsFor(options),
|
||||
handoff,
|
||||
availableModes: Object.entries(judgementModes).map(([id, item]) => ({ id, label: item.label })),
|
||||
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),
|
||||
},
|
||||
buildOrderDetails: {
|
||||
doFirst: compactBuildItems(options.filter(item => item.lane.id === 'do')),
|
||||
validateNext: compactBuildItems(options.filter(item => item.lane.id === 'test')),
|
||||
defer: compactBuildItems(options.filter(item => item.lane.id === 'defer')),
|
||||
park: compactBuildItems(options.filter(item => item.lane.id === 'park')),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user