diff --git a/README.md b/README.md index 7bf7f85..5310e6a 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Ranker's continuation job is narrow: `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, lane-level `buildOrderDetails` with each item's reason, next step, evidence question, source section, lane source, score, and confidence, 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 merges these sources rather than letting a shallow wrapper context shadow deeper Concept Map guardrails. Lens-only Concept Maps may additionally send audience / constraints / assumptions / risk lens content, and Ranker will split sentence-style lens notes into readable decision context 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. +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 merges these sources rather than letting a shallow wrapper context shadow deeper Concept Map guardrails. Lens-only Concept Maps may additionally send audience / constraints / assumptions / risk lens content, and Ranker will split sentence-style lens notes into readable decision context 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. Ranker also accepts Scattermind's paid Concept Map object directly when it arrives with top-level `reference_code`, `working_name`, `ideaText`, and string-valued `lenses.channel` / `lenses.risk` fields; the reference code becomes source provenance, the working name becomes the source title, and labelled Build Order text is turned into rank-ready candidates without requiring Scattermind to wrap it first. Lane safety note: explicit Scattermind `defer` / `park` hints are hard rails, not mild suggestions. Source `nonGoals` / `avoid` guardrails are also hard enough to keep conflicting candidates out of Do first / Validate next even when their local scoring hints look attractive; the result will mark the lane source as `source-non-goal` so the handoff can explain that the candidate needs guardrail resolution before active work. diff --git a/scripts/check-rank-feedback.mjs b/scripts/check-rank-feedback.mjs index 1c09415..774ad51 100644 --- a/scripts/check-rank-feedback.mjs +++ b/scripts/check-rank-feedback.mjs @@ -417,6 +417,34 @@ try { assert.ok(lensOnly.ranked.find(item => item.id === 'build-order-4').metrics.nonGoalConflicts.length >= 1); assert.deepEqual(lensOnly.handoff.warnings, []); + const scattermindPaidShapeResponse = await fetch(`${base}/api/rank-feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + reference_code: 'SM-PAID1', + working_name: 'Neighborhood Supper Club', + ideaText: 'I want to test a tiny paid supper club before building a food community app.', + context: 'Solo operator. Manual proof first. Avoid accounts and saved workspaces before anyone pays.', + mode: 'mvp', + lenses: { + audience: 'First buyers are local neighbors who already attend small food events and want a low-pressure dinner plan.', + risk: 'Avoid accounts and saved workspaces before anyone pays. Do not build subscriptions or an app dashboard yet.', + channel: 'Build first: One manual supper-club offer page - collect three real yes/no replies before building machinery. Test manually: Concierge invitation script - ask ten neighbors if they would pay for the first dinner. Defer: Pretty event calendar after the first paid table. Probably noise: Saved member workspace with accounts, billing, subscriptions, and team dashboard.', + }, + }), + }); + assert.equal(scattermindPaidShapeResponse.status, 200); + const scattermindPaidShape = await scattermindPaidShapeResponse.json(); + assert.equal(scattermindPaidShape.input.provenance.artifactId, 'SM-PAID1'); + assert.equal(scattermindPaidShape.input.provenance.snapshotTitle, 'Neighborhood Supper Club'); + assert.match(scattermindPaidShape.input.provenance.originalPrompt, /supper club/); + assert.equal(scattermindPaidShape.input.optionCount, 4); + assert.equal(scattermindPaidShape.ranked[0].id, 'build-order-1'); + assert.equal(scattermindPaidShape.ranked.find(item => item.id === 'build-order-4').lane.id, 'park'); + assert.ok(scattermindPaidShape.input.decisionContext.nonGoals.includes('Avoid accounts and saved workspaces before anyone pays')); + assert.ok(scattermindPaidShape.ranked.find(item => item.id === 'build-order-4').metrics.nonGoalConflicts.length >= 1); + assert.deepEqual(scattermindPaidShape.handoff.warnings, []); + const mergedContextResponse = await fetch(`${base}/api/rank-feedback`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -457,7 +485,7 @@ try { assert.equal(mergedContext.ranked.find(item => item.id === 'workspace-dashboard').lane.source, 'source-non-goal'); assert.deepEqual(mergedContext.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, mergedContextTop: mergedContext.ranked[0].id, duplicateIds: duplicateIds.ranked.map(item => item.id), provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2)); + 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, scattermindPaidShapeTop: scattermindPaidShape.ranked[0].id, mergedContextTop: mergedContext.ranked[0].id, duplicateIds: duplicateIds.ranked.map(item => item.id), provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2)); } finally { server.kill('SIGTERM'); } diff --git a/server.js b/server.js index e340c5d..8fa7234 100644 --- a/server.js +++ b/server.js @@ -455,15 +455,16 @@ function cleanProvenance(input = {}) { return { 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), + artifactId: cleanText(input.artifactId || input.sourceArtifactId || input.referenceCode || input.reference_code || artifact.id || source.artifactId || conceptMap.artifactId || conceptMap.id || conceptMap.referenceCode || conceptMap.reference_code || snapshot.artifactId || snapshot.id || '', 120), + snapshotTitle: cleanText(input.snapshotTitle || input.working_name || input.workingName || artifact.snapshotTitle || snapshot.title || snapshot.name || conceptMap.snapshotTitle || conceptMap.working_name || conceptMap.workingName || input.ideaTitle || '', 160), + conceptMapId: cleanText(input.conceptMapId || artifact.conceptMapId || conceptMap.id || conceptMap.artifactId || input.referenceCode || input.reference_code || conceptMap.referenceCode || conceptMap.reference_code || '', 120), + originalPrompt: cleanMultiline(input.originalPrompt || input.initialPrompt || input.ideaText || input.prompt || artifact.originalPrompt || source.originalPrompt || snapshot.originalPrompt || snapshot.prompt || conceptMap.originalPrompt || conceptMap.ideaText || '', 1200), }; } function lensContent(lens = {}) { if (Array.isArray(lens)) return lens; + if (typeof lens === 'string' || typeof lens === 'number') return String(lens); const obj = objectFrom(lens); return obj.content || obj.text || obj.summary || obj.items || ''; } @@ -692,7 +693,12 @@ 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 buildOrderText = lensContent(conceptMapLenses.channel) + || lensContent(conceptMapLenses.buildOrder) + || lensContent(conceptMap.buildOrder) + || buildOrderLens.content + || buildOrderLens.text + || ''; const buildOrderOptions = optionsFromBuildOrderText(buildOrderText); if (buildOrderOptions.length) return normalizeCandidateGroup([{ items: buildOrderOptions, sourceSection: 'concept-map.lenses.channel' }]); if (Array.isArray(body.options)) {