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`
|
`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 `conceptMap.nextActions / nextMoves` artifact directly, so Scattermind can hand off Concept Map next actions without renaming them into fake software features. It also returns 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 `conceptMap.nextActions / nextMoves` artifact directly, so Scattermind can hand off Concept Map next actions without renaming them into fake software features. 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.
|
||||||
|
|
||||||
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).
|
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).
|
||||||
|
|
||||||
|
|||||||
+21
-9
@@ -1,14 +1,14 @@
|
|||||||
const sample = {
|
const sample = {
|
||||||
idea: 'I’m building a lightweight product feedback tool for people with too many ideas and too many possible features. It should feel useful before becoming a workspace.',
|
idea: 'Scattermind clarified a messy course idea. Now I need the first build order, not a dashboard.',
|
||||||
optionsText: `- Ranked feedback map for pasted feature lists
|
optionsText: `- Manual build-order preview from one Concept Map
|
||||||
- Expert reflections on the top options
|
- Copyable decision brief with Do first / Validate next / Defer / Park
|
||||||
|
- Evidence questions beside each next move
|
||||||
- Accounts and saved workspaces
|
- Accounts and saved workspaces
|
||||||
- Team voting on feature priority
|
- Team voting on roadmap priority
|
||||||
- Exportable decision brief for Slack or Notion
|
- Subscription billing layer
|
||||||
- Custom criteria builder
|
- Polished export for sharing the defended order`,
|
||||||
- Paid deeper product strategy pass`,
|
context: 'Snapshot / Concept Map handoff, solo builder, tired non-AI-native user, avoid auth/workspaces/billing before proof.',
|
||||||
context: 'MVP, solo builder, needs to feel valuable in under two minutes, avoid dashboard swamp.',
|
mode: 'mvp',
|
||||||
mode: 'progress',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const form = document.querySelector('#rankForm');
|
const form = document.querySelector('#rankForm');
|
||||||
@@ -89,6 +89,18 @@ function renderResults(data) {
|
|||||||
<span>Next 48 hours</span>
|
<span>Next 48 hours</span>
|
||||||
<ol>${(brief.next48Hours || []).map((step) => `<li>${escapeHtml(step)}</li>`).join('')}</ol>
|
<ol>${(brief.next48Hours || []).map((step) => `<li>${escapeHtml(step)}</li>`).join('')}</ol>
|
||||||
</article>
|
</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>
|
||||||
|
` : ''}
|
||||||
|
${(brief.assumptions || []).length ? `
|
||||||
|
<article class="brief-card">
|
||||||
|
<span>Context carried forward</span>
|
||||||
|
<ul>${brief.assumptions.map((assumption) => `<li>${escapeHtml(assumption)}</li>`).join('')}</ul>
|
||||||
|
</article>
|
||||||
|
` : ''}
|
||||||
${(brief.expertReflections || []).map((reflection) => `
|
${(brief.expertReflections || []).map((reflection) => `
|
||||||
<article class="brief-card">
|
<article class="brief-card">
|
||||||
<span>${escapeHtml(reflection.lens)}</span>
|
<span>${escapeHtml(reflection.lens)}</span>
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ try {
|
|||||||
assert.ok(data.brief.next48Hours.some(item => /Open the source artifact \(snapshot_123\)/i.test(item)));
|
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)));
|
assert.ok(data.brief.next48Hours.some(item => /Evidence to collect/i.test(item)));
|
||||||
assert.match(data.brief.summary, /nearest follow-up|strongest signal/i);
|
assert.match(data.brief.summary, /nearest follow-up|strongest signal/i);
|
||||||
|
assert.ok(data.brief.whatWouldChangeRanking.some(item => /evidence fails|re-run the order/i.test(item)));
|
||||||
|
assert.ok(Array.isArray(data.brief.assumptions));
|
||||||
|
|
||||||
const hintedResponse = await fetch(`${base}/api/rank-feedback`, {
|
const hintedResponse = await fetch(`${base}/api/rank-feedback`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -197,6 +199,8 @@ try {
|
|||||||
const nonGoal = await nonGoalResponse.json();
|
const nonGoal = await nonGoalResponse.json();
|
||||||
assert.equal(nonGoal.input.decisionContext.targetAudience, 'Tired non-AI-native solo operator');
|
assert.equal(nonGoal.input.decisionContext.targetAudience, 'Tired non-AI-native solo operator');
|
||||||
assert.deepEqual(nonGoal.input.decisionContext.nonGoals, ['Avoid saved workspaces', 'No auth dashboard', 'No billing layer before proof']);
|
assert.deepEqual(nonGoal.input.decisionContext.nonGoals, ['Avoid saved workspaces', 'No auth dashboard', 'No billing layer before proof']);
|
||||||
|
assert.ok(nonGoal.brief.assumptions.includes('Constraint: No account before first value'));
|
||||||
|
assert.ok(nonGoal.brief.assumptions.includes('Non-goal: Avoid saved workspaces'));
|
||||||
assert.equal(nonGoal.ranked[0].id, 'manual-next-move', 'non-goal conflicts should beat flashy positive hints');
|
assert.equal(nonGoal.ranked[0].id, 'manual-next-move', 'non-goal conflicts should beat flashy positive hints');
|
||||||
const workspace = nonGoal.ranked.find(item => item.id === 'workspace-autopilot');
|
const workspace = nonGoal.ranked.find(item => item.id === 'workspace-autopilot');
|
||||||
assert.ok(workspace.metrics.nonGoalConflicts.length >= 2);
|
assert.ok(workspace.metrics.nonGoalConflicts.length >= 2);
|
||||||
|
|||||||
@@ -598,13 +598,29 @@ function concernFor(option) {
|
|||||||
return 'The main risk is sequencing: do it only if it supports the first useful proof.';
|
return 'The main risk is sequencing: do it only if it supports the first useful proof.';
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDecisionBrief({ idea, context, mode, ranked, provenance }) {
|
function whatWouldChangeRanking(top, second, risky) {
|
||||||
|
if (!top) return ['Add at least two concrete next moves with evidence needed.'];
|
||||||
|
const changes = [];
|
||||||
|
if (top.factors?.evidenceNeeded) changes.push(`If evidence fails for “${top.title}” (${top.factors.evidenceNeeded}), move it out of Do first.`);
|
||||||
|
else changes.push(`If “${top.title}” cannot name a cheap proof step, demote it until the evidence question is clear.`);
|
||||||
|
if (second) changes.push(`If “${second.title}” gets stronger proof or meaningfully lower effort, it can overtake the current first move.`);
|
||||||
|
if (risky && risky.id !== top.id) changes.push(`If the riskiest assumption around “${risky.title}” is answered cheaply, re-rank before building around it.`);
|
||||||
|
changes.push('If the source Concept Map adds new constraints or non-goals, re-run the order instead of editing around stale context.');
|
||||||
|
return changes.slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDecisionBrief({ idea, context, mode, ranked, provenance, decisionContext }) {
|
||||||
const top = ranked[0];
|
const top = ranked[0];
|
||||||
const second = ranked[1];
|
const second = ranked[1];
|
||||||
const risky = ranked.slice().sort((a, b) => b.metrics.risk - a.metrics.risk)[0];
|
const risky = ranked.slice().sort((a, b) => b.metrics.risk - a.metrics.risk)[0];
|
||||||
const deferred = ranked.filter(item => ['defer', 'park'].includes(item.lane.id)).slice(0, 3);
|
const deferred = ranked.filter(item => ['defer', 'park'].includes(item.lane.id)).slice(0, 3);
|
||||||
const sourceLabel = [provenance?.snapshotTitle, provenance?.artifactId].filter(Boolean).join(' · ');
|
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 theme = top ? `The strongest signal is “${top.title}” because ${reasonFor(top)}.` : 'The list needs at least two options before ranking becomes useful.';
|
||||||
|
const assumptions = [
|
||||||
|
...(decisionContext?.assumptions || []),
|
||||||
|
...(decisionContext?.constraints || []).map(item => `Constraint: ${item}`),
|
||||||
|
...(decisionContext?.nonGoals || []).map(item => `Non-goal: ${item}`),
|
||||||
|
].slice(0, 6);
|
||||||
return {
|
return {
|
||||||
headline: top ? `Start with ${top.title}` : 'Add options to get a ranked feedback map',
|
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}.` : ''}`,
|
summary: `${theme}${second ? ` “${second.title}” is the nearest follow-up, not a parallel first step.` : ''}${sourceLabel ? ` Source: ${sourceLabel}.` : ''}`,
|
||||||
@@ -635,6 +651,8 @@ function createDecisionBrief({ idea, context, mode, ranked, provenance }) {
|
|||||||
'Put it in front of 3–5 real people or run it manually once.',
|
'Put it in front of 3–5 real people or run it manually once.',
|
||||||
`Do not touch ${deferred[0] ? `“${deferred[0].title}”` : 'the parked ideas'} until the first signal is real.`,
|
`Do not touch ${deferred[0] ? `“${deferred[0].title}”` : 'the parked ideas'} until the first signal is real.`,
|
||||||
] : ['Paste 3–10 options.', 'Choose what the ranking should care about.', 'Run the first-pass judgement.'],
|
] : ['Paste 3–10 options.', 'Choose what the ranking should care about.', 'Run the first-pass judgement.'],
|
||||||
|
assumptions,
|
||||||
|
whatWouldChangeRanking: whatWouldChangeRanking(top, second, risky),
|
||||||
caution: 'This is first-pass judgement, not an oracle. Change the criteria if the context changes.',
|
caution: 'This is first-pass judgement, not an oracle. Change the criteria if the context changes.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -701,7 +719,7 @@ app.post('/api/rank-feedback', (req, res) => {
|
|||||||
concern: concernFor(rankedOption),
|
concern: concernFor(rankedOption),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const brief = createDecisionBrief({ idea, context, mode, ranked: options, provenance });
|
const brief = createDecisionBrief({ idea, context, mode, ranked: options, provenance, decisionContext });
|
||||||
const handoff = createHandoffContract({ ranked: options, provenance, decisionContext });
|
const handoff = createHandoffContract({ ranked: options, provenance, decisionContext });
|
||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user