diff --git a/README.md b/README.md index 737b48f..4bcaf77 100644 --- a/README.md +++ b/README.md @@ -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`, 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 `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. diff --git a/scripts/check-rank-feedback.mjs b/scripts/check-rank-feedback.mjs index 80e2d4e..2ae0c67 100644 --- a/scripts/check-rank-feedback.mjs +++ b/scripts/check-rank-feedback.mjs @@ -139,7 +139,37 @@ try { assert.equal(actions.ranked.find(item => item.id === 'saved-workspace').lane.id, 'park'); assert.deepEqual(actions.handoff.warnings, []); - console.log(JSON.stringify({ ok: true, top: data.ranked[0].id, hintedTop: hinted.ranked[0].id, actionTop: actions.ranked[0].id, provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2)); + const nestedConceptResponse = await fetch(`${base}/api/rank-feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceName: 'Scattermind', + idea: 'Scattermind produced a Concept Map artifact with nested next actions.', + context: 'Ranker should consume the artifact directly and preserve provenance without asking Scattermind to rename actions as features.', + mode: 'mvp', + conceptMap: { + id: 'concept_map_nested_42', + snapshotTitle: 'Course idea continuation', + originalPrompt: 'I have a course idea and need the first build step.', + nextActions: [ + { id: 'landing-proof', action: 'One-page promise test', why: 'A tired creator can see whether the promise lands before building lessons.', evidence: 'Can 5 target users describe the promised outcome?', validationSteps: ['Write the promise', 'Ask 5 target users'], rankerHints: { value: 9, effort: 2, confidence: 8, urgency: 8, risk: 2 }, suggestedLane: 'do-first' }, + { id: 'lesson-library', action: 'Full lesson library', why: 'Build every module, account dashboard, saved progress, and team workspace.', dependencies: ['auth', 'content system', 'progress tracking', 'workspace'], risk: 'Large platform before promise proof.', suggestedLane: 'park' }, + { id: 'copyable-plan', action: 'Copyable build order brief', why: 'Gives the creator a concrete next step to paste into notes.', evidence: 'Does the brief trigger one real follow-up action?', suggestedLane: 'validate-next' }, + ], + }, + }), + }); + assert.equal(nestedConceptResponse.status, 200); + const nestedConcept = await nestedConceptResponse.json(); + assert.equal(nestedConcept.input.provenance.artifactId, 'concept_map_nested_42'); + assert.equal(nestedConcept.input.provenance.conceptMapId, 'concept_map_nested_42'); + assert.equal(nestedConcept.handoff.source.hasOriginalPrompt, true); + assert.equal(nestedConcept.ranked[0].id, 'landing-proof'); + assert.equal(nestedConcept.ranked.find(item => item.id === 'landing-proof').provenance.sourceSection, 'concept-map.nextActions'); + assert.equal(nestedConcept.ranked.find(item => item.id === 'lesson-library').lane.id, 'park'); + assert.deepEqual(nestedConcept.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, provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2)); } finally { server.kill('SIGTERM'); } diff --git a/server.js b/server.js index efbba0e..2d6f171 100644 --- a/server.js +++ b/server.js @@ -390,27 +390,34 @@ function parseOptionsFromText(value) { }).filter(item => item.title); } +function objectFrom(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; +} + function cleanProvenance(input = {}) { - const artifact = input.artifact && typeof input.artifact === 'object' ? input.artifact : {}; - const source = input.source && typeof input.source === 'object' ? input.source : {}; + const featureSet = objectFrom(input.featureSet); + const artifact = objectFrom(input.artifact || featureSet.artifact); + const conceptMap = objectFrom(input.conceptMap || featureSet.conceptMap || artifact.conceptMap); + const snapshot = objectFrom(input.snapshot || featureSet.snapshot || artifact.snapshot || conceptMap.snapshot); + const source = objectFrom(input.source || featureSet.source || artifact.source); return { - schema: cleanText(input.schema || artifact.schema || input.type || 'prioritix-feature-set-v1', 80), - source: cleanText(input.sourceName || source.name || artifact.sourceName || 'Scattermind', 80), - 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), + schema: cleanText(input.schema || featureSet.schema || artifact.schema || input.type || 'prioritix-feature-set-v1', 80), + source: cleanText(input.sourceName || featureSet.sourceName || source.name || artifact.sourceName || 'Scattermind', 80), + artifactId: cleanText(input.artifactId || input.sourceArtifactId || artifact.id || source.artifactId || conceptMap.artifactId || conceptMap.id || snapshot.artifactId || snapshot.id || '', 120), + snapshotTitle: cleanText(input.snapshotTitle || artifact.snapshotTitle || snapshot.title || snapshot.name || conceptMap.snapshotTitle || input.ideaTitle || '', 160), + conceptMapId: cleanText(input.conceptMapId || artifact.conceptMapId || conceptMap.id || conceptMap.artifactId || '', 120), + originalPrompt: cleanMultiline(input.originalPrompt || input.initialPrompt || artifact.originalPrompt || source.originalPrompt || snapshot.originalPrompt || snapshot.prompt || conceptMap.originalPrompt || '', 1200), }; } -function normalizeFeatureOption(item, index, fallbackId = 'feature') { +function normalizeFeatureOption(item, index, fallbackId = 'feature', defaultSourceSection = '') { 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 sourceSection = cleanText(item?.sourceSection || item?.section || item?.lane || item?.origin || defaultSourceSection, 80); const recommendedLane = cleanText(item?.recommendedLane || item?.laneHint || item?.suggestedLane || '', 40).toLowerCase(); const descriptionParts = [ item?.description || item?.brief || '', @@ -432,29 +439,35 @@ function normalizeFeatureOption(item, index, fallbackId = 'feature') { }; } -function firstArray(...values) { - return values.find(Array.isArray) || null; +function candidateArrayFrom(...entries) { + return entries.find(entry => Array.isArray(entry?.items)) || null; } function optionsFromBody(body = {}) { - const featureSet = body.featureSet && typeof body.featureSet === 'object' ? body.featureSet : {}; - const rawCandidates = firstArray( - body.features, - featureSet.features, - body.actions, - featureSet.actions, - body.nextMoves, - featureSet.nextMoves, - body.candidates, - featureSet.candidates + const featureSet = objectFrom(body.featureSet); + const conceptMap = objectFrom(body.conceptMap || featureSet.conceptMap); + const rawCandidates = candidateArrayFrom( + { items: body.features, sourceSection: 'features' }, + { items: featureSet.features, sourceSection: 'feature-set.features' }, + { items: body.actions, sourceSection: 'actions' }, + { items: featureSet.actions, sourceSection: 'feature-set.actions' }, + { items: body.nextMoves, sourceSection: 'nextMoves' }, + { items: featureSet.nextMoves, sourceSection: 'feature-set.nextMoves' }, + { items: body.candidates, sourceSection: 'candidates' }, + { items: featureSet.candidates, sourceSection: 'feature-set.candidates' }, + { items: conceptMap.nextActions, sourceSection: 'concept-map.nextActions' }, + { items: conceptMap.nextMoves, sourceSection: 'concept-map.nextMoves' }, + { items: conceptMap.features, sourceSection: 'concept-map.features' }, + { items: conceptMap.candidates, sourceSection: 'concept-map.candidates' } ); if (rawCandidates) { - return rawCandidates.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index)).filter(item => item.title); + const fallbackId = rawCandidates.sourceSection.toLowerCase().includes('action') ? 'action' : 'feature'; + return rawCandidates.items.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, fallbackId, rawCandidates.sourceSection)).filter(item => item.title); } if (Array.isArray(body.options)) { - return body.options.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, 'option')).filter(item => item.title); + return body.options.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, 'option', 'options')).filter(item => item.title); } - return parseOptionsFromText(body.optionsText || featureSet.optionsText || ''); + return parseOptionsFromText(body.optionsText || featureSet.optionsText || conceptMap.optionsText || ''); } function scoreOption(option, mode, context = '') {