Compare commits
6 Commits
36e8bfae58
...
25c7c08543
| Author | SHA1 | Date | |
|---|---|---|---|
| 25c7c08543 | |||
| c13f16c0e7 | |||
| 35b3e6a47d | |||
| adcef9a6f7 | |||
| 65832f9b56 | |||
| 7151f0d378 |
@@ -45,9 +45,11 @@ 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, which keeps partially-normalized Scattermind exports rankable. 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. 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`, or inside `conceptMap.context`; 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.
|
||||
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.
|
||||
|
||||
Lane safety note: explicit Scattermind `defer` / `park` hints are hard rails, not mild suggestions. Source `nonGoals` / `avoid` guardrails are also hard enough to keep conflicting candidates out of Do first / Validate next even when their local scoring hints look attractive; the result will mark the lane source as `source-non-goal` so the handoff can explain that the candidate needs guardrail resolution before active work.
|
||||
|
||||
Recommended payload shape:
|
||||
|
||||
|
||||
+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));
|
||||
@@ -80,6 +86,10 @@ try {
|
||||
assert.equal(data.handoff.source.hasOriginalPrompt, true);
|
||||
assert.equal(data.handoff.itemTrace.length, data.ranked.length);
|
||||
assert.equal(data.handoff.itemTrace.find(item => item.id === 'bridge-contract').sourceSection, 'concept-map.nextMoves');
|
||||
assert.ok(data.input.decisionContext.constraints.includes('Solo builder'));
|
||||
assert.ok(data.input.decisionContext.nonGoals.includes('Avoid accounts, workspaces, and team voting'));
|
||||
assert.deepEqual(data.handoff.decisionContext.nonGoals, ['Avoid accounts, workspaces, and team voting']);
|
||||
assert.ok(data.ranked.find(item => item.id === 'workspace').metrics.nonGoalConflicts.length >= 1, 'flat text avoid guardrails should protect against workspace candidates');
|
||||
assert.deepEqual(data.handoff.warnings, []);
|
||||
assert.ok(data.brief.next48Hours.some(item => /Open the source artifact \(snapshot_123\)/i.test(item)));
|
||||
assert.ok(data.brief.next48Hours.some(item => /Evidence to collect/i.test(item)));
|
||||
@@ -268,8 +278,47 @@ try {
|
||||
const workspace = nonGoal.ranked.find(item => item.id === 'workspace-autopilot');
|
||||
assert.ok(workspace.metrics.nonGoalConflicts.length >= 2);
|
||||
assert.match(workspace.concern, /Source context says not to do this yet/);
|
||||
assert.equal(workspace.lane.id, 'defer', 'source non-goals should keep conflicted candidates out of the active proof slice even with strong hints');
|
||||
assert.equal(workspace.lane.source, 'source-non-goal');
|
||||
assert.deepEqual(nonGoal.handoff.itemTrace.find(item => item.id === 'workspace-autopilot').nonGoalConflicts, workspace.metrics.nonGoalConflicts);
|
||||
assert.ok(!nonGoal.buildOrder.doFirst.includes('workspace-autopilot'));
|
||||
assert.ok(!nonGoal.buildOrder.validateNext.includes('workspace-autopilot'));
|
||||
assert.ok(!nonGoal.handoff.warnings.some(item => /active item workspace-autopilot conflicts/.test(item)), 'conflicted candidates should be demoted before handoff warnings need to flag active-lane conflict');
|
||||
|
||||
const mixedWrapperResponse = await fetch(`${base}/api/rank-feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceName: 'Scattermind',
|
||||
artifactId: 'concept_map_mixed_wrappers',
|
||||
originalPrompt: 'The bridge export contains a normalized feature list plus Concept Map validation and parking sections.',
|
||||
idea: 'Ranker should not lose sectioned Concept Map context when a partial feature wrapper is also present.',
|
||||
mode: 'mvp',
|
||||
featureSet: {
|
||||
features: [
|
||||
{ id: 'manual-bridge-proof', title: 'Manual bridge proof', description: 'Turn one Concept Map into a defended build-order preview.', evidenceNeeded: 'Can one tired user act on the preview?', recommendedLane: 'do-first', rankerHints: { value: 9, effort: 2, confidence: 8, urgency: 8, risk: 2 } },
|
||||
],
|
||||
},
|
||||
conceptMap: {
|
||||
validateNext: [
|
||||
{ id: 'copyable-brief-test', action: 'Copyable brief test', why: 'Check whether the decision brief remains useful outside Ranker.', evidence: 'Can the user paste it into notes and still know the next move?' },
|
||||
],
|
||||
parkingLot: [
|
||||
{ id: 'account-dashboard', action: 'Account dashboard', why: 'Saved workspaces, auth, billing, and team sync.', evidence: 'No bridge proof yet' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert.equal(mixedWrapperResponse.status, 200);
|
||||
const mixedWrapper = await mixedWrapperResponse.json();
|
||||
assert.equal(mixedWrapper.input.optionCount, 3);
|
||||
assert.equal(mixedWrapper.ranked[0].id, 'manual-bridge-proof');
|
||||
assert.equal(mixedWrapper.handoff.itemTrace.find(item => item.id === 'manual-bridge-proof').sourceSection, 'feature-set.features');
|
||||
assert.equal(mixedWrapper.ranked.find(item => item.id === 'copyable-brief-test').lane.id, 'test');
|
||||
assert.equal(mixedWrapper.handoff.itemTrace.find(item => item.id === 'copyable-brief-test').sourceSection, 'concept-map.validateNext');
|
||||
assert.equal(mixedWrapper.ranked.find(item => item.id === 'account-dashboard').lane.id, 'park');
|
||||
assert.equal(mixedWrapper.handoff.itemTrace.find(item => item.id === 'account-dashboard').sourceSection, 'concept-map.parkingLot');
|
||||
assert.deepEqual(mixedWrapper.handoff.warnings, []);
|
||||
|
||||
const duplicateIdResponse = await fetch(`${base}/api/rank-feedback`, {
|
||||
method: 'POST',
|
||||
@@ -298,7 +347,77 @@ try {
|
||||
assert.ok(duplicateIds.handoff.warnings.some(item => /duplicate source id preview normalized to preview-2/.test(item)));
|
||||
assert.ok(Object.values(duplicateIds.buildOrder).flat().includes('preview-2'));
|
||||
|
||||
console.log(JSON.stringify({ ok: true, top: data.ranked[0].id, hintedTop: hinted.ranked[0].id, actionTop: actions.ranked[0].id, nestedConceptTop: nestedConcept.ranked[0].id, nonGoalTop: nonGoal.ranked[0].id, duplicateIds: duplicateIds.ranked.map(item => item.id), provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2));
|
||||
const structuredContextResponse = await fetch(`${base}/api/rank-feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceName: 'Scattermind',
|
||||
artifactId: 'concept_map_structured_context',
|
||||
originalPrompt: 'Scattermind sent a structured context object instead of a flat context string.',
|
||||
idea: 'Ranker should preserve structured context and not turn it into [object Object].',
|
||||
mode: 'mvp',
|
||||
context: {
|
||||
summary: 'Bridge proof for a tired non-AI-native user.',
|
||||
targetAudience: 'Tired non-AI-native solo builder',
|
||||
constraints: ['No account before first value', 'Use a copyable artifact first'],
|
||||
nonGoals: ['Avoid auth dashboard', 'Avoid saved workspaces'],
|
||||
assumptions: ['Manual proof is acceptable for the first pass'],
|
||||
},
|
||||
conceptMap: {
|
||||
nextActions: [
|
||||
{ id: 'copyable-bridge-artifact', action: 'Copyable bridge artifact', why: 'Turn the source Concept Map into one defended build-order brief.', evidence: 'Can the user paste it and know the first move?', suggestedLane: 'do-first', rankerHints: { value: 9, effort: 2, confidence: 8, urgency: 8, risk: 2 } },
|
||||
{ id: 'auth-dashboard', action: 'Auth dashboard', why: 'Accounts and saved workspaces for every idea.', evidence: 'None yet', suggestedLane: 'do-first', rankerHints: { value: 10, effort: 2, confidence: 9, urgency: 9, risk: 2 } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert.equal(structuredContextResponse.status, 200);
|
||||
const structuredContext = await structuredContextResponse.json();
|
||||
assert.doesNotMatch(structuredContext.input.context, /\[object Object\]/);
|
||||
assert.match(structuredContext.input.context, /Target audience: Tired non-AI-native solo builder/);
|
||||
assert.deepEqual(structuredContext.input.decisionContext.constraints, ['No account before first value', 'Use a copyable artifact first']);
|
||||
assert.deepEqual(structuredContext.handoff.decisionContext.nonGoals, ['Avoid auth dashboard', 'Avoid saved workspaces']);
|
||||
assert.equal(structuredContext.ranked[0].id, 'copyable-bridge-artifact');
|
||||
assert.equal(structuredContext.ranked.find(item => item.id === 'auth-dashboard').lane.source, 'source-non-goal');
|
||||
|
||||
const lensOnlyResponse = await fetch(`${base}/api/rank-feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceName: 'Scattermind',
|
||||
artifactId: 'concept_map_lens_only',
|
||||
originalPrompt: 'Scattermind produced the current paid Concept Map shape: lenses with a Build Order paragraph, not arrays.',
|
||||
idea: 'Ranker should turn the Build Order lens into rank-ready candidates without asking Scattermind to rename fields first.',
|
||||
mode: 'mvp',
|
||||
conceptMap: {
|
||||
snapshotTitle: 'Lens-only Concept Map bridge',
|
||||
lenses: {
|
||||
risk: {
|
||||
title: 'What Can Mislead You',
|
||||
content: 'Avoid saved workspaces and auth dashboard before one manual proof. Do not build billing yet.',
|
||||
},
|
||||
channel: {
|
||||
title: 'Build Order',
|
||||
content: 'Build first: Manual build-order preview from one Concept Map - prove the bridge before adding product machinery. Test manually: Copyable decision brief - show it to 3 tired users and ask what they would do next. Defer: Visual polish pass after the rough artifact is understood. Probably noise: Saved workspace dashboard with auth, billing, and team collaboration.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert.equal(lensOnlyResponse.status, 200);
|
||||
const lensOnly = await lensOnlyResponse.json();
|
||||
assert.equal(lensOnly.input.optionCount, 4);
|
||||
assert.equal(lensOnly.ranked[0].id, 'build-order-1');
|
||||
assert.equal(lensOnly.ranked[0].provenance.sourceSection, 'concept-map.lenses.channel');
|
||||
assert.equal(lensOnly.ranked.find(item => item.id === 'build-order-2').lane.id, 'test');
|
||||
assert.equal(lensOnly.ranked.find(item => item.id === 'build-order-3').lane.id, 'defer');
|
||||
assert.equal(lensOnly.ranked.find(item => item.id === 'build-order-4').lane.id, 'park');
|
||||
assert.ok(lensOnly.input.decisionContext.nonGoals.includes('Avoid saved workspaces and auth dashboard before one manual proof'));
|
||||
assert.ok(lensOnly.input.decisionContext.nonGoals.includes('Do not build billing yet'));
|
||||
assert.ok(lensOnly.ranked.find(item => item.id === 'build-order-4').metrics.nonGoalConflicts.length >= 1);
|
||||
assert.deepEqual(lensOnly.handoff.warnings, []);
|
||||
|
||||
console.log(JSON.stringify({ ok: true, top: data.ranked[0].id, hintedTop: hinted.ranked[0].id, actionTop: actions.ranked[0].id, nestedConceptTop: nestedConcept.ranked[0].id, nonGoalTop: nonGoal.ranked[0].id, structuredContextTop: structuredContext.ranked[0].id, lensOnlyTop: lensOnly.ranked[0].id, duplicateIds: duplicateIds.ranked.map(item => item.id), provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2));
|
||||
} finally {
|
||||
server.kill('SIGTERM');
|
||||
}
|
||||
|
||||
@@ -157,6 +157,36 @@ function cleanFlexibleTextList(value, maxItems = 8, maxText = 180) {
|
||||
.slice(0, maxItems);
|
||||
}
|
||||
|
||||
function uniqueList(items = [], maxItems = 8) {
|
||||
const seen = new Set();
|
||||
return items.filter(item => {
|
||||
const cleaned = cleanText(item, 180);
|
||||
const key = cleaned.toLowerCase();
|
||||
if (!cleaned || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
}).slice(0, maxItems);
|
||||
}
|
||||
|
||||
function contextSentences(value = '') {
|
||||
return cleanMultiline(value, 3000)
|
||||
.split(/\n|;|\.|\|/)
|
||||
.map(item => item.replace(/^\s*[-*•\d.)]+\s*/, '').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function guardrailsFromContextText(value = '') {
|
||||
const nonGoals = [];
|
||||
const constraints = [];
|
||||
for (const sentence of contextSentences(value)) {
|
||||
const cleaned = cleanText(sentence, 180);
|
||||
if (!cleaned) continue;
|
||||
if (/^(avoid|no|do not|don't|dont|must not|never)\b/i.test(cleaned)) nonGoals.push(cleaned);
|
||||
else if (/\b(avoid|no auth|no account|no billing|no workspace|not a dashboard|without accounts|before proof|manual proof|solo builder|constraint)\b/i.test(cleaned)) constraints.push(cleaned);
|
||||
}
|
||||
return { nonGoals: uniqueList(nonGoals), constraints: uniqueList(constraints) };
|
||||
}
|
||||
|
||||
function cleanMetricHints(item = {}) {
|
||||
const raw = {
|
||||
...(item.factors && typeof item.factors === 'object' ? item.factors : {}),
|
||||
@@ -363,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',
|
||||
@@ -393,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);
|
||||
}
|
||||
@@ -422,21 +462,55 @@ function cleanProvenance(input = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function firstObject(...values) {
|
||||
for (const value of values) {
|
||||
const obj = objectFrom(value);
|
||||
if (Object.keys(obj).length) return obj;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function cleanDecisionContext(input = {}) {
|
||||
const featureSet = objectFrom(input.featureSet);
|
||||
const artifact = objectFrom(input.artifact || featureSet.artifact);
|
||||
const conceptMap = objectFrom(input.conceptMap || featureSet.conceptMap || artifact.conceptMap);
|
||||
const sourceContext = objectFrom(input.decisionContext || featureSet.decisionContext || artifact.decisionContext || conceptMap.decisionContext || conceptMap.context);
|
||||
const conceptMapLenses = objectFrom(conceptMap.lenses || input.lenses || featureSet.lenses);
|
||||
const riskLens = objectFrom(conceptMapLenses.risk);
|
||||
const structuredContext = objectFrom(input.context);
|
||||
const sourceContext = firstObject(input.decisionContext, featureSet.decisionContext, artifact.decisionContext, conceptMap.decisionContext, structuredContext, conceptMap.context);
|
||||
const textContextGuardrails = guardrailsFromContextText([
|
||||
typeof input.context === 'string' ? input.context : '',
|
||||
riskLens.content || riskLens.text || '',
|
||||
conceptMap.risk || conceptMap.whatNotToBuildYet || conceptMap.notYet || '',
|
||||
].filter(Boolean).join('\n'));
|
||||
return {
|
||||
targetAudience: cleanText(input.targetAudience || featureSet.targetAudience || sourceContext.targetAudience || conceptMap.targetAudience || '', 180),
|
||||
constraints: cleanFlexibleTextList(input.constraints || featureSet.constraints || sourceContext.constraints || conceptMap.constraints, 8, 180),
|
||||
nonGoals: cleanFlexibleTextList(input.nonGoals || input.avoid || featureSet.nonGoals || featureSet.avoid || sourceContext.nonGoals || sourceContext.avoid || conceptMap.nonGoals || conceptMap.avoid, 8, 180),
|
||||
constraints: uniqueList([
|
||||
...cleanFlexibleTextList(input.constraints || featureSet.constraints || sourceContext.constraints || conceptMap.constraints, 8, 180),
|
||||
...textContextGuardrails.constraints,
|
||||
], 8),
|
||||
nonGoals: uniqueList([
|
||||
...cleanFlexibleTextList(input.nonGoals || input.avoid || featureSet.nonGoals || featureSet.avoid || sourceContext.nonGoals || sourceContext.avoid || conceptMap.nonGoals || conceptMap.avoid, 8, 180),
|
||||
...textContextGuardrails.nonGoals,
|
||||
], 8),
|
||||
assumptions: cleanFlexibleTextList(input.assumptions || featureSet.assumptions || sourceContext.assumptions || conceptMap.assumptions, 6, 180),
|
||||
};
|
||||
}
|
||||
|
||||
function cleanContextText(value = '') {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return cleanMultiline(value || '', 3000);
|
||||
const pieces = [
|
||||
value.summary || value.description || value.notes || value.brief || '',
|
||||
value.targetAudience && `Target audience: ${value.targetAudience}`,
|
||||
...cleanFlexibleTextList(value.constraints, 8, 180).map(item => `Constraint: ${item}`),
|
||||
...cleanFlexibleTextList(value.nonGoals || value.avoid, 8, 180).map(item => `Non-goal: ${item}`),
|
||||
...cleanFlexibleTextList(value.assumptions, 6, 180).map(item => `Assumption: ${item}`),
|
||||
].filter(Boolean);
|
||||
return cleanMultiline(pieces.join('\n'), 3000);
|
||||
}
|
||||
|
||||
function meaningfulTokens(text = '') {
|
||||
const stop = new Set(['the', 'and', 'with', 'from', 'that', 'this', 'into', 'before', 'after', 'user', 'users', 'idea', 'ideas', 'build', 'first', 'avoid', 'without', 'more', 'full', 'proof', 'value', 'layer']);
|
||||
const stop = new Set(['the', 'and', 'with', 'from', 'that', 'this', 'into', 'before', 'after', 'user', 'users', 'idea', 'ideas', 'build', 'first', 'avoid', 'without', 'more', 'full', 'proof', 'manual', 'value', 'layer']);
|
||||
return [...new Set(String(text).toLowerCase().match(/[a-z0-9][a-z0-9-]{3,}/g) || [])].filter(token => !stop.has(token)).slice(0, 8);
|
||||
}
|
||||
|
||||
@@ -496,12 +570,8 @@ function normalizeOptionIds(options = []) {
|
||||
});
|
||||
}
|
||||
|
||||
function candidateArrayFrom(...entries) {
|
||||
return entries.find(entry => Array.isArray(entry?.items) && entry.items.length > 0) || null;
|
||||
}
|
||||
|
||||
function candidateGroupFrom(...groups) {
|
||||
return groups.find(group => group.some(entry => Array.isArray(entry?.items) && entry.items.length > 0)) || null;
|
||||
function compactCandidateGroup(group = []) {
|
||||
return group.filter(entry => Array.isArray(entry?.items) && entry.items.length > 0);
|
||||
}
|
||||
|
||||
function normalizeCandidateGroup(group = []) {
|
||||
@@ -512,10 +582,55 @@ function normalizeCandidateGroup(group = []) {
|
||||
return normalizeOptionIds(options);
|
||||
}
|
||||
|
||||
function sentenceFragments(text = '') {
|
||||
return cleanMultiline(text, 4000)
|
||||
.replace(/\s+(build first|start here|ship first|test manually|validate next|defer|probably noise|park|do not build yet|don't build yet)\s*:/gi, '\n$1:')
|
||||
.split(/\n|;|\s+[•-]\s+/)
|
||||
.map(part => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function titleFromBuildOrderFragment(value = '') {
|
||||
const cleaned = cleanText(value.replace(/^(build first|start here|ship first|test manually|validate next|defer|probably noise|park|do not build yet|don't build yet)\s*:\s*/i, ''), 220);
|
||||
const first = cleaned.split(/\s[-–—:]\s|\.\s/)[0] || cleaned;
|
||||
return cleanText(first, 120);
|
||||
}
|
||||
|
||||
function laneFromBuildOrderLabel(fragment = '') {
|
||||
if (/^(build first|start here|ship first)\s*:/i.test(fragment)) return 'do-first';
|
||||
if (/^(test manually|validate next)\s*:/i.test(fragment)) return 'validate-next';
|
||||
if (/^(defer|do not build yet|don't build yet)\s*:/i.test(fragment)) return 'defer';
|
||||
if (/^(probably noise|park)\s*:/i.test(fragment)) return 'park';
|
||||
return '';
|
||||
}
|
||||
|
||||
function optionsFromBuildOrderText(text = '', sourceSection = 'concept-map.lenses.channel') {
|
||||
const fragments = sentenceFragments(text);
|
||||
const labelled = fragments.filter(fragment => laneFromBuildOrderLabel(fragment));
|
||||
return labelled.map((fragment, index) => {
|
||||
const lane = laneFromBuildOrderLabel(fragment);
|
||||
return {
|
||||
id: `build-order-${index + 1}`,
|
||||
action: titleFromBuildOrderFragment(fragment),
|
||||
why: fragment.replace(/^\s*[^:]{1,40}:\s*/, '').trim(),
|
||||
evidence: /test|validate|proof|prove|signal|ask|show/i.test(fragment) ? fragment : (lane === 'do-first' ? 'Prove this first move manually before adding product machinery.' : ''),
|
||||
suggestedLane: lane,
|
||||
rankerHints: lane === 'do-first'
|
||||
? { value: 8, effort: 2, confidence: 7, urgency: 7, risk: 2 }
|
||||
: lane === 'validate-next'
|
||||
? { value: 7, effort: 3, confidence: 6, urgency: 5, risk: 3 }
|
||||
: undefined,
|
||||
sourceSection,
|
||||
};
|
||||
}).filter(item => item.action);
|
||||
}
|
||||
|
||||
function optionsFromBody(body = {}) {
|
||||
const featureSet = objectFrom(body.featureSet);
|
||||
const conceptMap = objectFrom(body.conceptMap || featureSet.conceptMap);
|
||||
const rawCandidates = candidateArrayFrom(
|
||||
const conceptMapLenses = objectFrom(conceptMap.lenses || body.lenses || featureSet.lenses);
|
||||
const buildOrderLens = objectFrom(conceptMapLenses.channel || conceptMapLenses.buildOrder || conceptMap.buildOrder);
|
||||
const directCandidateGroup = compactCandidateGroup([
|
||||
{ items: body.features, sourceSection: 'features' },
|
||||
{ items: featureSet.features, sourceSection: 'feature-set.features' },
|
||||
{ items: body.actions, sourceSection: 'actions' },
|
||||
@@ -523,13 +638,9 @@ function optionsFromBody(body = {}) {
|
||||
{ items: body.nextMoves, sourceSection: 'nextMoves' },
|
||||
{ items: featureSet.nextMoves, sourceSection: 'feature-set.nextMoves' },
|
||||
{ items: body.candidates, sourceSection: 'candidates' },
|
||||
{ items: featureSet.candidates, sourceSection: 'feature-set.candidates' }
|
||||
);
|
||||
if (rawCandidates) {
|
||||
const fallbackId = rawCandidates.sourceSection.toLowerCase().includes('action') ? 'action' : 'feature';
|
||||
return normalizeOptionIds(rawCandidates.items.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, fallbackId, rawCandidates.sourceSection)).filter(item => item.title));
|
||||
}
|
||||
const conceptMapCandidates = candidateGroupFrom([
|
||||
{ items: featureSet.candidates, sourceSection: 'feature-set.candidates' },
|
||||
]);
|
||||
const conceptMapCandidateGroup = compactCandidateGroup([
|
||||
{ items: conceptMap.nextActions, sourceSection: 'concept-map.nextActions' },
|
||||
{ items: conceptMap.nextMoves, sourceSection: 'concept-map.nextMoves' },
|
||||
{ items: conceptMap.features, sourceSection: 'concept-map.features' },
|
||||
@@ -538,7 +649,11 @@ function optionsFromBody(body = {}) {
|
||||
{ items: conceptMap.deferred || conceptMap.defer || conceptMap.later, sourceSection: 'concept-map.deferred', defaultLane: 'defer' },
|
||||
{ items: conceptMap.parkingLot || conceptMap.park || conceptMap.parked, sourceSection: 'concept-map.parkingLot', defaultLane: 'park' },
|
||||
]);
|
||||
if (conceptMapCandidates) return normalizeCandidateGroup(conceptMapCandidates);
|
||||
const groupedCandidates = [...directCandidateGroup, ...conceptMapCandidateGroup];
|
||||
if (groupedCandidates.length) return normalizeCandidateGroup(groupedCandidates);
|
||||
const buildOrderText = buildOrderLens.content || buildOrderLens.text || (typeof conceptMap.buildOrder === 'string' ? conceptMap.buildOrder : '');
|
||||
const buildOrderOptions = optionsFromBuildOrderText(buildOrderText);
|
||||
if (buildOrderOptions.length) return normalizeCandidateGroup([{ items: buildOrderOptions, sourceSection: 'concept-map.lenses.channel' }]);
|
||||
if (Array.isArray(body.options)) {
|
||||
return normalizeOptionIds(body.options.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, 'option', 'options')).filter(item => item.title));
|
||||
}
|
||||
@@ -559,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),
|
||||
@@ -600,11 +715,13 @@ function normalizeLaneHint(value = '') {
|
||||
function laneFor(option, rankIndex, total) {
|
||||
const hintedLane = normalizeLaneHint(option.factors?.recommendedLane || '');
|
||||
// Ranker should defend build order, not blindly obey Scattermind. Positive
|
||||
// hints can nudge scoring, but explicit negative hints are safety rails:
|
||||
// if the source already marked something as defer/park, never promote it
|
||||
// hints can nudge scoring, but explicit negative hints and source non-goals
|
||||
// are safety rails: if the source already marked something as not-now, or
|
||||
// if the candidate conflicts with the source guardrails, never promote it
|
||||
// into the active proof slice just because keyword scoring liked it.
|
||||
if (hintedLane === 'park') return { id: 'park', label: 'Park / cut', action: 'Keep out of the active plan', source: 'hint' };
|
||||
if (hintedLane === 'defer') return { id: 'defer', label: 'Defer', action: 'Sequence after proof', source: 'hint' };
|
||||
if (option.metrics?.nonGoalConflicts?.length) return { id: 'defer', label: 'Defer', action: 'Resolve source guardrail first', source: 'source-non-goal' };
|
||||
if (rankIndex === 0) return { id: 'do', label: 'Do first', action: 'Build/test now' };
|
||||
if (hintedLane === 'test') return { id: 'test', label: 'Validate next', action: 'Find evidence', source: 'hint' };
|
||||
if (rankIndex < Math.max(2, Math.ceil(total * 0.32))) return { id: 'test', label: 'Validate next', action: 'Find evidence' };
|
||||
@@ -612,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';
|
||||
}
|
||||
@@ -628,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.';
|
||||
@@ -635,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 = [];
|
||||
@@ -653,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}`),
|
||||
@@ -661,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,
|
||||
@@ -694,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');
|
||||
@@ -740,7 +968,7 @@ function createHandoffContract({ ranked, provenance, decisionContext }) {
|
||||
|
||||
app.post('/api/rank-feedback', (req, res) => {
|
||||
const idea = cleanMultiline(req.body?.idea || '', 3000);
|
||||
const context = cleanMultiline(req.body?.context || '', 3000);
|
||||
const context = cleanContextText(req.body?.context || '');
|
||||
const modeId = cleanText(req.body?.mode || 'progress', 40);
|
||||
const mode = judgementModes[modeId] || judgementModes.progress;
|
||||
const provenance = cleanProvenance(req.body || {});
|
||||
@@ -757,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 });
|
||||
@@ -767,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