Tighten Scattermind rank feedback contract
This commit is contained in:
@@ -39,6 +39,46 @@ Tables:
|
||||
- `milestones` — name, description, horizon, color, position, active
|
||||
- `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
|
||||
|
||||
```bash
|
||||
|
||||
@@ -40,16 +40,17 @@ try {
|
||||
sourceName: 'Scattermind',
|
||||
artifactId: 'snapshot_123',
|
||||
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.',
|
||||
context: 'Solo builder. Need a rank-ready build order after Snapshot / Concept Map. Avoid accounts, workspaces, and team voting.',
|
||||
mode: 'mvp',
|
||||
featureSet: {
|
||||
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: 'build-order-preview', title: 'Build order preview', description: 'Show do first, validate next, defer, and park with reasons.' },
|
||||
{ id: 'workspace', title: 'Accounts and saved workspaces', description: 'Full dashboard with auth, workspace collaboration, team voting, and sync.' },
|
||||
{ id: 'billing', title: 'Subscription billing layer', description: 'Pricing, checkout, invoices, account plans, and admin controls.' },
|
||||
{ id: 'export', title: 'Exportable decision brief', description: 'Simple brief for sharing the defended build order.' },
|
||||
{ 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.', 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.', 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.', 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.', 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.input.provenance.artifactId, 'snapshot_123');
|
||||
assert.equal(data.input.provenance.source, 'Scattermind');
|
||||
assert.match(data.input.provenance.originalPrompt, /tiny shop idea/);
|
||||
assert.equal(data.ranked.length, 5);
|
||||
assert.deepEqual(Object.keys(data.buildOrder), ['doFirst', 'validateNext', 'defer', 'park']);
|
||||
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.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.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);
|
||||
console.log(JSON.stringify({ ok: true, top: data.ranked[0].id, provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2));
|
||||
} finally {
|
||||
|
||||
@@ -140,6 +140,11 @@ function encodeList(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) {
|
||||
const rows = result?.rows || result?.documents;
|
||||
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),
|
||||
snapshotTitle: cleanText(input.snapshotTitle || artifact.snapshotTitle || input.ideaTitle || '', 160),
|
||||
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 rawFeatures = Array.isArray(body.features) ? body.features : Array.isArray(featureSet.features) ? featureSet.features : null;
|
||||
if (rawFeatures) {
|
||||
return rawFeatures.slice(0, 24).map((item, index) => ({
|
||||
id: cleanText(item?.id || item?.key || `feature-${index + 1}`, 80) || `feature-${index + 1}`,
|
||||
title: cleanText(item?.title || item?.name || item?.action || '', 140),
|
||||
description: cleanText(item?.description || item?.brief || item?.why || item?.evidenceNeeded || '', 520),
|
||||
provenance: {
|
||||
sourceId: cleanText(item?.sourceId || item?.sourceArtifactId || item?.id || '', 120),
|
||||
sourceSection: cleanText(item?.sourceSection || item?.section || item?.lane || '', 80),
|
||||
},
|
||||
})).filter(item => item.title);
|
||||
return rawFeatures.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index)).filter(item => item.title);
|
||||
}
|
||||
if (Array.isArray(body.options)) {
|
||||
return body.options.slice(0, 24).map((item, index) => ({
|
||||
id: cleanText(item?.id || `option-${index + 1}`, 80) || `option-${index + 1}`,
|
||||
title: cleanText(item?.title || item?.name || '', 140),
|
||||
description: cleanText(item?.description || item?.brief || '', 420),
|
||||
})).filter(item => item.title);
|
||||
return body.options.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, 'option')).filter(item => item.title);
|
||||
}
|
||||
return parseOptionsFromText(body.optionsText || featureSet.optionsText || '');
|
||||
}
|
||||
|
||||
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 riskHits = hits(text, wordSets.risk);
|
||||
const coreLoopHits = hits(text, ['ranked feedback map', 'feedback map', 'pasted feature', 'feature lists', 'first-pass', 'decision brief', 'expert reflections']);
|
||||
const bridgeHits = hits(text, ['snapshot', 'concept map', 'feature set', 'build order', 'rank-ready', 'provenance', 'next moves']);
|
||||
const 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 = {
|
||||
value: Math.min(10, 4.8 + hits(text, wordSets.value) * 0.9 + coreLoopHits * 0.8 + bridgeHits * 0.75 + hits(context, wordSets.value) * 0.15),
|
||||
feasibility: Math.max(1, Math.min(10, 8.2 - effortHits * 0.8 - riskHits * 0.35 + Math.min(1.1, coreLoopHits * 0.28 + bridgeHits * 0.18))),
|
||||
confidence: Math.max(1, Math.min(10, 5.8 + coreLoopHits * 0.35 + bridgeHits * 0.28 + hits(text, ['manual', 'existing', 'already', 'simple', 'clear', 'known']) * 0.7 - hits(text, ['maybe', 'unknown', 'new market', 'all users']) * 0.9)),
|
||||
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 - 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 + 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),
|
||||
revenue: Math.min(10, 3.8 + hits(text, wordSets.revenue) * 1.05),
|
||||
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 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 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 };
|
||||
}
|
||||
|
||||
function laneFor(option, rankIndex, total) {
|
||||
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' };
|
||||
return { id: 'defer', label: 'Defer', action: 'Sequence after proof' };
|
||||
}
|
||||
@@ -426,6 +455,7 @@ function laneFor(option, rankIndex, total) {
|
||||
function reasonFor(option) {
|
||||
const m = option.metrics;
|
||||
if (option.lane?.id === 'do' && /snapshot|concept map|feature set|build order|rank/i.test(`${option.title} ${option.description}`)) return 'it strengthens the Scattermind → Ranker bridge instead of inventing a generic workspace';
|
||||
if (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.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';
|
||||
@@ -435,6 +465,7 @@ function reasonFor(option) {
|
||||
|
||||
function concernFor(option) {
|
||||
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.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.';
|
||||
@@ -467,6 +498,7 @@ function createDecisionBrief({ idea, context, mode, ranked }) {
|
||||
],
|
||||
next48Hours: top ? [
|
||||
`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 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.`,
|
||||
] : ['Paste 3–10 options.', 'Choose what the ranking should care about.', 'Run the first-pass judgement.'],
|
||||
|
||||
Reference in New Issue
Block a user