Tighten Scattermind rank feedback contract

This commit is contained in:
OpenClaw Bot
2026-05-26 22:17:47 +02:00
parent e532c6d910
commit 8b7477e323
3 changed files with 103 additions and 26 deletions
+40
View File
@@ -39,6 +39,46 @@ Tables:
- `milestones` — name, description, horizon, color, position, active - `milestones` — name, description, horizon, color, position, active
- `activity` — small append-only UX feed - `activity` — small append-only UX feed
## Scattermind → Ranker bridge
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`. 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.
Recommended payload shape:
```json
{
"schema": "prioritix-feature-set-v1",
"sourceName": "Scattermind",
"artifactId": "snapshot_or_concept_map_id",
"snapshotTitle": "Plain idea title",
"conceptMapId": "optional_concept_map_id",
"originalPrompt": "The user's starting prompt, trimmed for provenance",
"idea": "What Scattermind clarified",
"context": "Important constraints: solo builder, non-AI-native user, avoid dashboard swamp, etc.",
"mode": "mvp",
"featureSet": {
"features": [
{
"id": "build-order-preview",
"title": "Build order preview",
"description": "Show do first, validate next, defer, and park with reasons.",
"userValue": "A tired builder sees the next move without opening a dashboard.",
"evidenceNeeded": "Can 3 non-AI-native users understand the first recommended action?",
"proofSteps": ["Show a static result screen to 3 people"],
"dependencies": [],
"risk": "May become generic roadmap UI if the source context is lost.",
"recommendedLane": "validate-next",
"sourceSection": "concept-map.nextMoves"
}
]
}
}
```
## Commands ## Commands
```bash ```bash
+10 -5
View File
@@ -40,16 +40,17 @@ try {
sourceName: 'Scattermind', sourceName: 'Scattermind',
artifactId: 'snapshot_123', artifactId: 'snapshot_123',
snapshotTitle: 'Tiny shop idea clarity pass', snapshotTitle: 'Tiny shop idea clarity pass',
originalPrompt: 'I have a tiny shop idea and do not know what to build first.',
idea: 'Scattermind clarified a small product idea. Ranker must defend the next build order, not create a dashboard.', idea: 'Scattermind clarified a small product idea. Ranker must defend the next build order, not create a dashboard.',
context: 'Solo builder. Need a rank-ready build order after Snapshot / Concept Map. Avoid accounts, workspaces, and team voting.', context: 'Solo builder. Need a rank-ready build order after Snapshot / Concept Map. Avoid accounts, workspaces, and team voting.',
mode: 'mvp', mode: 'mvp',
featureSet: { featureSet: {
features: [ features: [
{ id: 'bridge-contract', title: 'Snapshot to Ranker feature-set contract', description: 'Convert Concept Map next moves into a rank-ready feature set with provenance.' }, { id: 'bridge-contract', title: 'Snapshot to Ranker feature-set contract', description: 'Convert Concept Map next moves into a rank-ready feature set with provenance.', userValue: 'Preserves the user prompt and source artifact so build order is defensible.', evidenceNeeded: 'Can one generated Concept Map create 3-7 sane next moves with source sections?', proofSteps: ['Run one fixture through /api/rank-feedback'], recommendedLane: 'do-first', sourceSection: 'concept-map.nextMoves' },
{ id: 'build-order-preview', title: 'Build order preview', description: 'Show do first, validate next, defer, and park with reasons.' }, { id: 'build-order-preview', title: 'Build order preview', description: 'Show do first, validate next, defer, and park with reasons.', userValue: 'A tired builder sees the next move without opening a dashboard.', evidenceNeeded: 'Can 3 non-AI-native users understand the first recommended action?', proofSteps: ['Show a static result screen to 3 people'], recommendedLane: 'validate-next' },
{ id: 'workspace', title: 'Accounts and saved workspaces', description: 'Full dashboard with auth, workspace collaboration, team voting, and sync.' }, { id: 'workspace', title: 'Accounts and saved workspaces', description: 'Full dashboard with auth, workspace collaboration, team voting, and sync.', dependencies: ['auth', 'teams', 'saved projects', 'sync'], risk: 'Turns the bridge into generic dashboard swamp before the first proof.' },
{ id: 'billing', title: 'Subscription billing layer', description: 'Pricing, checkout, invoices, account plans, and admin controls.' }, { id: 'billing', title: 'Subscription billing layer', description: 'Pricing, checkout, invoices, account plans, and admin controls.', dependencies: ['account model', 'checkout', 'fulfillment'], risk: 'Payment machinery before the continuation value is proven.' },
{ id: 'export', title: 'Exportable decision brief', description: 'Simple brief for sharing the defended build order.' }, { id: 'export', title: 'Exportable decision brief', description: 'Simple brief for sharing the defended build order.', evidenceNeeded: 'Does a plain brief help a user act within 48 hours?', recommendedLane: 'validate-next' },
], ],
}, },
}), }),
@@ -59,12 +60,16 @@ try {
assert.equal(data.ok, true); assert.equal(data.ok, true);
assert.equal(data.input.provenance.artifactId, 'snapshot_123'); assert.equal(data.input.provenance.artifactId, 'snapshot_123');
assert.equal(data.input.provenance.source, 'Scattermind'); assert.equal(data.input.provenance.source, 'Scattermind');
assert.match(data.input.provenance.originalPrompt, /tiny shop idea/);
assert.equal(data.ranked.length, 5); assert.equal(data.ranked.length, 5);
assert.deepEqual(Object.keys(data.buildOrder), ['doFirst', 'validateNext', 'defer', 'park']); assert.deepEqual(Object.keys(data.buildOrder), ['doFirst', 'validateNext', 'defer', 'park']);
assert.equal(data.ranked[0].id, data.buildOrder.doFirst[0]); assert.equal(data.ranked[0].id, data.buildOrder.doFirst[0]);
assert.notEqual(data.ranked[0].id, 'workspace', 'dashboard swamp must not win the bridge fixture'); 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 === 'workspace').lane.id));
assert.ok(['defer', 'park'].includes(data.ranked.find(item => item.id === 'billing').lane.id)); assert.ok(['defer', 'park'].includes(data.ranked.find(item => item.id === 'billing').lane.id));
assert.equal(data.ranked.find(item => item.id === 'bridge-contract').provenance.sourceSection, 'concept-map.nextMoves');
assert.match(data.ranked.find(item => item.id === 'bridge-contract').factors.evidenceNeeded, /Concept Map/);
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);
console.log(JSON.stringify({ ok: true, top: data.ranked[0].id, provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2)); console.log(JSON.stringify({ ok: true, top: data.ranked[0].id, provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2));
} finally { } finally {
+53 -21
View File
@@ -140,6 +140,11 @@ function encodeList(value) {
return JSON.stringify(parseList(value)); return JSON.stringify(parseList(value));
} }
function cleanTextList(value, maxItems = 6, maxText = 180) {
const list = Array.isArray(value) ? value : parseList(value);
return list.map(item => cleanText(item, maxText)).filter(Boolean).slice(0, maxItems);
}
function rowsFrom(result) { function rowsFrom(result) {
const rows = result?.rows || result?.documents; const rows = result?.rows || result?.documents;
if (!Array.isArray(rows)) throw new Error('Appwrite returned an invalid table response'); if (!Array.isArray(rows)) throw new Error('Appwrite returned an invalid table response');
@@ -367,6 +372,36 @@ function cleanProvenance(input = {}) {
artifactId: cleanText(input.artifactId || input.sourceArtifactId || artifact.id || source.artifactId || '', 120), artifactId: cleanText(input.artifactId || input.sourceArtifactId || artifact.id || source.artifactId || '', 120),
snapshotTitle: cleanText(input.snapshotTitle || artifact.snapshotTitle || input.ideaTitle || '', 160), snapshotTitle: cleanText(input.snapshotTitle || artifact.snapshotTitle || input.ideaTitle || '', 160),
conceptMapId: cleanText(input.conceptMapId || artifact.conceptMapId || '', 120), conceptMapId: cleanText(input.conceptMapId || artifact.conceptMapId || '', 120),
originalPrompt: cleanMultiline(input.originalPrompt || input.initialPrompt || artifact.originalPrompt || source.originalPrompt || '', 1200),
};
}
function normalizeFeatureOption(item, index, fallbackId = 'feature') {
const title = cleanText(item?.title || item?.name || item?.action || '', 140);
const proofSteps = cleanTextList(item?.proofSteps || item?.proof || item?.validationSteps, 5, 180);
const dependencies = cleanTextList(item?.dependencies || item?.blockedBy, 5, 120);
const evidenceNeeded = cleanText(item?.evidenceNeeded || item?.evidence || item?.test || '', 260);
const userValue = cleanText(item?.userValue || item?.value || item?.outcome || item?.why, 260);
const risk = cleanText(item?.risk || item?.assumption || item?.unknown || '', 220);
const sourceSection = cleanText(item?.sourceSection || item?.section || item?.lane || item?.origin || '', 80);
const recommendedLane = cleanText(item?.recommendedLane || item?.laneHint || item?.suggestedLane || '', 40).toLowerCase();
const descriptionParts = [
item?.description || item?.brief || '',
userValue && `User value: ${userValue}`,
evidenceNeeded && `Evidence needed: ${evidenceNeeded}`,
risk && `Risk: ${risk}`,
proofSteps.length && `Proof steps: ${proofSteps.join('; ')}`,
dependencies.length && `Dependencies: ${dependencies.join(', ')}`,
].filter(Boolean);
return {
id: cleanText(item?.id || item?.key || `${fallbackId}-${index + 1}`, 80) || `${fallbackId}-${index + 1}`,
title,
description: cleanText(descriptionParts.join(' '), 760),
factors: { userValue, evidenceNeeded, risk, proofSteps, dependencies, recommendedLane },
provenance: {
sourceId: cleanText(item?.sourceId || item?.sourceArtifactId || item?.id || '', 120),
sourceSection,
},
}; };
} }
@@ -374,51 +409,45 @@ function optionsFromBody(body = {}) {
const featureSet = body.featureSet && typeof body.featureSet === 'object' ? body.featureSet : {}; const featureSet = body.featureSet && typeof body.featureSet === 'object' ? body.featureSet : {};
const rawFeatures = Array.isArray(body.features) ? body.features : Array.isArray(featureSet.features) ? featureSet.features : null; const rawFeatures = Array.isArray(body.features) ? body.features : Array.isArray(featureSet.features) ? featureSet.features : null;
if (rawFeatures) { if (rawFeatures) {
return rawFeatures.slice(0, 24).map((item, index) => ({ return rawFeatures.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index)).filter(item => item.title);
id: cleanText(item?.id || item?.key || `feature-${index + 1}`, 80) || `feature-${index + 1}`,
title: cleanText(item?.title || item?.name || item?.action || '', 140),
description: cleanText(item?.description || item?.brief || item?.why || item?.evidenceNeeded || '', 520),
provenance: {
sourceId: cleanText(item?.sourceId || item?.sourceArtifactId || item?.id || '', 120),
sourceSection: cleanText(item?.sourceSection || item?.section || item?.lane || '', 80),
},
})).filter(item => item.title);
} }
if (Array.isArray(body.options)) { if (Array.isArray(body.options)) {
return body.options.slice(0, 24).map((item, index) => ({ return body.options.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, 'option')).filter(item => item.title);
id: cleanText(item?.id || `option-${index + 1}`, 80) || `option-${index + 1}`,
title: cleanText(item?.title || item?.name || '', 140),
description: cleanText(item?.description || item?.brief || '', 420),
})).filter(item => item.title);
} }
return parseOptionsFromText(body.optionsText || featureSet.optionsText || ''); return parseOptionsFromText(body.optionsText || featureSet.optionsText || '');
} }
function scoreOption(option, mode, context = '') { function scoreOption(option, mode, context = '') {
const text = `${option.title} ${option.description} ${context}`; const factors = option.factors || {};
const text = `${option.title} ${option.description} ${factors.userValue || ''} ${factors.evidenceNeeded || ''} ${factors.risk || ''} ${(factors.proofSteps || []).join(' ')} ${context}`;
const effortHits = hits(text, wordSets.effort); const effortHits = hits(text, wordSets.effort);
const riskHits = hits(text, wordSets.risk); const riskHits = hits(text, wordSets.risk);
const coreLoopHits = hits(text, ['ranked feedback map', 'feedback map', 'pasted feature', 'feature lists', 'first-pass', 'decision brief', 'expert reflections']); const coreLoopHits = hits(text, ['ranked feedback map', 'feedback map', 'pasted feature', 'feature lists', 'first-pass', 'decision brief', 'expert reflections']);
const bridgeHits = hits(text, ['snapshot', 'concept map', 'feature set', 'build order', 'rank-ready', 'provenance', 'next moves']); const bridgeHits = hits(text, ['snapshot', 'concept map', 'feature set', 'build order', 'rank-ready', 'provenance', 'next moves']);
const proofHits = hits(text, ['evidence needed', 'proof steps', 'manual', 'test with', 'validate', '3 users', 'interview', 'mock', 'prototype']);
const swampHits = hits(text, ['dashboard', 'workspace', 'workspaces', 'auth', 'accounts', 'billing', 'subscription', 'team voting', 'collaboration', 'admin']);
const dependencyPenalty = Math.min(2.2, (factors.dependencies || []).length * 0.45);
const laneHint = factors.recommendedLane || '';
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 metrics = { const metrics = {
value: Math.min(10, 4.8 + hits(text, wordSets.value) * 0.9 + coreLoopHits * 0.8 + bridgeHits * 0.75 + hits(context, wordSets.value) * 0.15), 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),
feasibility: Math.max(1, Math.min(10, 8.2 - effortHits * 0.8 - riskHits * 0.35 + Math.min(1.1, coreLoopHits * 0.28 + bridgeHits * 0.18))), feasibility: Math.max(1, Math.min(10, 8.2 - effortHits * 0.8 - riskHits * 0.35 - dependencyPenalty + Math.min(1.1, coreLoopHits * 0.28 + bridgeHits * 0.18 + proofHits * 0.12))),
confidence: Math.max(1, Math.min(10, 5.8 + coreLoopHits * 0.35 + bridgeHits * 0.28 + hits(text, ['manual', 'existing', 'already', 'simple', 'clear', 'known']) * 0.7 - hits(text, ['maybe', 'unknown', 'new market', 'all users']) * 0.9)), confidence: Math.max(1, Math.min(10, 5.8 + coreLoopHits * 0.35 + bridgeHits * 0.28 + proofHits * 0.32 + hits(text, ['manual', 'existing', 'already', 'simple', 'clear', 'known']) * 0.7 - hits(text, ['maybe', 'unknown', 'new market', 'all users']) * 0.9)),
urgency: Math.min(10, 4.9 + hits(text, wordSets.urgency) * 0.9), urgency: Math.min(10, 4.9 + hits(text, wordSets.urgency) * 0.9),
revenue: Math.min(10, 3.8 + hits(text, wordSets.revenue) * 1.05), revenue: Math.min(10, 3.8 + hits(text, wordSets.revenue) * 1.05),
novelty: Math.min(10, 4.1 + hits(text, wordSets.novelty) * 0.95), novelty: Math.min(10, 4.1 + hits(text, wordSets.novelty) * 0.95),
risk: Math.min(10, 2.5 + riskHits * 1.1 + Math.max(0, effortHits - 2) * 0.45), risk: Math.min(10, 2.5 + riskHits * 1.1 + Math.max(0, effortHits - 2) * 0.45 + swampHits * 0.2 + dependencyPenalty),
}; };
const weights = mode.weights; const weights = mode.weights;
const weighted = Object.entries(weights).reduce((sum, [key, weight]) => sum + metrics[key] * weight, 0); const weighted = Object.entries(weights).reduce((sum, [key, weight]) => sum + metrics[key] * weight, 0);
const possible = Object.entries(weights).reduce((sum, [_key, weight]) => sum + Math.abs(weight) * 10, 0); const possible = Object.entries(weights).reduce((sum, [_key, weight]) => sum + Math.abs(weight) * 10, 0);
const score = Math.max(0, Math.min(100, Math.round((weighted + Math.abs(Math.min(0, weights.risk || 0)) * 10) / possible * 100))); const score = Math.max(0, Math.min(100, Math.round(((weighted + laneBoost) + Math.abs(Math.min(0, weights.risk || 0)) * 10) / possible * 100)));
return { ...metrics, score }; return { ...metrics, score };
} }
function laneFor(option, rankIndex, total) { function laneFor(option, rankIndex, total) {
if (rankIndex === 0) return { id: 'do', label: 'Do first', action: 'Build/test now' }; if (rankIndex === 0) return { id: 'do', label: 'Do first', action: 'Build/test now' };
if (rankIndex <= Math.max(1, Math.ceil(total * 0.32))) return { id: 'test', label: 'Validate next', action: 'Find evidence' }; if (rankIndex < Math.max(2, Math.ceil(total * 0.32))) return { id: 'test', label: 'Validate next', action: 'Find evidence' };
if (rankIndex >= Math.max(2, Math.floor(total * 0.72))) return { id: 'park', label: 'Park / cut', action: 'Keep out of the active plan' }; if (rankIndex >= Math.max(2, Math.floor(total * 0.72))) return { id: 'park', label: 'Park / cut', action: 'Keep out of the active plan' };
return { id: 'defer', label: 'Defer', action: 'Sequence after proof' }; return { id: 'defer', label: 'Defer', action: 'Sequence after proof' };
} }
@@ -426,6 +455,7 @@ function laneFor(option, rankIndex, total) {
function reasonFor(option) { function reasonFor(option) {
const m = option.metrics; const m = option.metrics;
if (option.lane?.id === 'do' && /snapshot|concept map|feature set|build order|rank/i.test(`${option.title} ${option.description}`)) return 'it strengthens the Scattermind → Ranker bridge instead of inventing a generic workspace'; if (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 (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 'high enough value with low enough delivery drag to create fast signal'; if (m.feasibility >= 7.2 && m.value >= 6.2) return 'high enough value with low enough delivery drag to create fast signal';
if (m.revenue >= 6.4) return 'clearer buyer or money signal than the rest of the list'; if (m.revenue >= 6.4) return 'clearer buyer or money signal than the rest of the list';
if (m.risk >= 6.5) return 'interesting, but it carries assumption risk that should be tested before build'; if (m.risk >= 6.5) return 'interesting, but it carries assumption risk that should be tested before build';
@@ -435,6 +465,7 @@ function reasonFor(option) {
function concernFor(option) { function concernFor(option) {
const m = option.metrics; const m = option.metrics;
if ((option.factors?.dependencies || []).length >= 3) return 'Too many prerequisites. Split the proof slice before treating this as build-ready.';
if (m.risk >= 6.5) return 'The hidden risk is pretending this is ready to build before the core assumption is proven.'; 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.feasibility <= 4.5) return 'The likely trap is scope creep: this may need too much machinery for an MVP.';
if (m.confidence <= 4.5) return 'Evidence looks thin. Treat this as a question, not a roadmap item.'; if (m.confidence <= 4.5) return 'Evidence looks thin. Treat this as a question, not a roadmap item.';
@@ -467,6 +498,7 @@ function createDecisionBrief({ idea, context, mode, ranked }) {
], ],
next48Hours: top ? [ next48Hours: top ? [
`Write a one-paragraph test for “${top.title}”.`, `Write a one-paragraph test for “${top.title}”.`,
top.factors?.evidenceNeeded ? `Evidence to collect: ${top.factors.evidenceNeeded}.` : 'Name the evidence that would make this decision obviously right or wrong.',
'Put it in front of 35 real people or run it manually once.', 'Put it in front of 35 real people or run it manually once.',
`Do not touch ${deferred[0] ? `${deferred[0].title}` : 'the parked ideas'} until the first signal is real.`, `Do not touch ${deferred[0] ? `${deferred[0].title}` : 'the parked ideas'} until the first signal is real.`,
] : ['Paste 310 options.', 'Choose what the ranking should care about.', 'Run the first-pass judgement.'], ] : ['Paste 310 options.', 'Choose what the ranking should care about.', 'Run the first-pass judgement.'],