Accept lens-only Scattermind build orders
This commit is contained in:
@@ -45,7 +45,7 @@ Ranker's continuation job is narrow:
|
||||
|
||||
`Snapshot / Concept Map → candidate feature/action set → Rank-ready build order`
|
||||
|
||||
`POST /api/rank-feedback` accepts a `prioritix-feature-set-v1`-style payload from Scattermind and returns ranked items plus `buildOrder.doFirst / validateNext / defer / park`. It accepts candidate arrays as `features`, `actions`, `nextMoves`, or `candidates` either at the top level or under `featureSet`, and it can consume a nested Concept Map directly, so Scattermind can hand off `conceptMap.nextActions / nextMoves` without renaming them into fake software features. Sectioned Concept Maps may also include `validateNext`, `deferred`, and `parkingLot`; Ranker combines those sections into one build-order pass while preserving `sourceSection` and treating deferred/parked sections as lane hints. Empty wrapper arrays are ignored rather than allowed to shadow a real nested Concept Map action set, and non-empty normalized wrappers are merged with Concept Map validation/deferred/parking sections rather than dropping that context. That keeps partially-normalized Scattermind exports rankable without losing the source lane contract. It also returns a `brief` with source, next-48-hour actions, carried-forward assumptions/constraints/non-goals, and `whatWouldChangeRanking` checks, plus a `handoff` object (`rank-feedback-result-v1`) with source provenance, item trace rows, and contract warnings for missing artifact IDs, source sections, original prompt provenance, or evidence on active items. 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, plus a `handoff` object (`rank-feedback-result-v1`) with source provenance, item trace rows, and contract warnings for missing artifact IDs, source sections, original prompt provenance, or evidence on active items. If Scattermind sends the current paid Concept Map shape as lenses rather than arrays, Ranker can parse `conceptMap.lenses.channel.content` / `buildOrder` labels (`Build first`, `Test manually`, `Defer`, `Probably noise`) into rank-ready candidates and can read guardrails from the risk lens. Keep this contract action-first; do not use it as a reason to add generic dashboard, auth, billing, or workspace layers before the bridge has proof.
|
||||
|
||||
Candidate items may include optional 1–10 `rankerHints` (`value`, `effort`, `confidence`, `urgency`, `revenue`, `novelty`, `risk`). Ranker blends those explicit Scattermind hints with text heuristics; `effort` is inverted into feasibility. Hints improve the defended order, but `recommendedLane: "defer"` or `"park"` remains a safety rail so dashboard-swamp items do not get promoted by flashy wording. For clean bridge handoff, Scattermind should send `sourceSection` and `evidenceNeeded` on each active next move. Scattermind can also send `targetAudience`, `constraints`, `assumptions`, and `nonGoals` / `avoid` at the top level, in `featureSet`, inside `conceptMap.context`, or as a structured top-level `context` object with `summary`, `targetAudience`, `constraints`, `nonGoals` / `avoid`, and `assumptions`; Ranker turns that structured context into readable scoring text instead of leaking `[object Object]`. If Scattermind only has a flat context string, Ranker now extracts simple guardrails such as `Solo builder`, `Manual proof`, `Avoid ...`, `No ...`, and `Do not ...` into `input.decisionContext` / `handoff.decisionContext` so early bridge exports still protect against dashboard/auth/billing drift. Ranker returns that decision context in `input.decisionContext` and `handoff.decisionContext`, and penalizes candidates that conflict with source non-goals (for example saved workspaces/auth/billing before the continuation proof). If Scattermind sends duplicate candidate IDs, Ranker keeps the first ID, suffixes later duplicates (`preview-2`), and reports the normalization in `handoff.warnings` / `handoff.itemTrace` so downstream build-order references remain addressable.
|
||||
|
||||
|
||||
@@ -374,7 +374,44 @@ try {
|
||||
assert.equal(structuredContext.ranked[0].id, 'copyable-bridge-artifact');
|
||||
assert.equal(structuredContext.ranked.find(item => item.id === 'auth-dashboard').lane.source, 'source-non-goal');
|
||||
|
||||
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, duplicateIds: duplicateIds.ranked.map(item => item.id), provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2));
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -464,9 +464,15 @@ 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 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 : '');
|
||||
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: uniqueList([
|
||||
@@ -494,7 +500,7 @@ function cleanContextText(value = '') {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -566,9 +572,54 @@ 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 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' },
|
||||
@@ -590,6 +641,9 @@ function optionsFromBody(body = {}) {
|
||||
]);
|
||||
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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user