diff --git a/README.md b/README.md index 1337f6c..72ca91c 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, 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. -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`, or inside `conceptMap.context`; 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 turns that structured context into readable scoring text instead of leaking `[object Object]`. 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. 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 84bcd6c..57d5531 100644 --- a/scripts/check-rank-feedback.mjs +++ b/scripts/check-rank-feedback.mjs @@ -337,7 +337,40 @@ try { assert.ok(duplicateIds.handoff.warnings.some(item => /duplicate source id preview normalized to preview-2/.test(item))); assert.ok(Object.values(duplicateIds.buildOrder).flat().includes('preview-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, duplicateIds: duplicateIds.ranked.map(item => item.id), provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2)); + const structuredContextResponse = await fetch(`${base}/api/rank-feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceName: 'Scattermind', + artifactId: 'concept_map_structured_context', + originalPrompt: 'Scattermind sent a structured context object instead of a flat context string.', + idea: 'Ranker should preserve structured context and not turn it into [object Object].', + mode: 'mvp', + context: { + summary: 'Bridge proof for a tired non-AI-native user.', + targetAudience: 'Tired non-AI-native solo builder', + constraints: ['No account before first value', 'Use a copyable artifact first'], + nonGoals: ['Avoid auth dashboard', 'Avoid saved workspaces'], + assumptions: ['Manual proof is acceptable for the first pass'], + }, + conceptMap: { + nextActions: [ + { id: 'copyable-bridge-artifact', action: 'Copyable bridge artifact', why: 'Turn the source Concept Map into one defended build-order brief.', evidence: 'Can the user paste it and know the first move?', suggestedLane: 'do-first', rankerHints: { value: 9, effort: 2, confidence: 8, urgency: 8, risk: 2 } }, + { id: 'auth-dashboard', action: 'Auth dashboard', why: 'Accounts and saved workspaces for every idea.', evidence: 'None yet', suggestedLane: 'do-first', rankerHints: { value: 10, effort: 2, confidence: 9, urgency: 9, risk: 2 } }, + ], + }, + }), + }); + assert.equal(structuredContextResponse.status, 200); + const structuredContext = await structuredContextResponse.json(); + assert.doesNotMatch(structuredContext.input.context, /\[object Object\]/); + assert.match(structuredContext.input.context, /Target audience: Tired non-AI-native solo builder/); + assert.deepEqual(structuredContext.input.decisionContext.constraints, ['No account before first value', 'Use a copyable artifact first']); + assert.deepEqual(structuredContext.handoff.decisionContext.nonGoals, ['Avoid auth dashboard', 'Avoid saved workspaces']); + 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)); } finally { server.kill('SIGTERM'); } diff --git a/server.js b/server.js index 97c1c72..8995db9 100644 --- a/server.js +++ b/server.js @@ -422,11 +422,20 @@ function cleanProvenance(input = {}) { }; } +function firstObject(...values) { + for (const value of values) { + const obj = objectFrom(value); + if (Object.keys(obj).length) return obj; + } + return {}; +} + 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 sourceContext = objectFrom(input.decisionContext || featureSet.decisionContext || artifact.decisionContext || conceptMap.decisionContext || conceptMap.context); + const structuredContext = objectFrom(input.context); + const sourceContext = firstObject(input.decisionContext, featureSet.decisionContext, artifact.decisionContext, conceptMap.decisionContext, structuredContext, conceptMap.context); return { targetAudience: cleanText(input.targetAudience || featureSet.targetAudience || sourceContext.targetAudience || conceptMap.targetAudience || '', 180), constraints: cleanFlexibleTextList(input.constraints || featureSet.constraints || sourceContext.constraints || conceptMap.constraints, 8, 180), @@ -435,6 +444,18 @@ function cleanDecisionContext(input = {}) { }; } +function cleanContextText(value = '') { + if (!value || typeof value !== 'object' || Array.isArray(value)) return cleanMultiline(value || '', 3000); + const pieces = [ + value.summary || value.description || value.notes || value.brief || '', + value.targetAudience && `Target audience: ${value.targetAudience}`, + ...cleanFlexibleTextList(value.constraints, 8, 180).map(item => `Constraint: ${item}`), + ...cleanFlexibleTextList(value.nonGoals || value.avoid, 8, 180).map(item => `Non-goal: ${item}`), + ...cleanFlexibleTextList(value.assumptions, 6, 180).map(item => `Assumption: ${item}`), + ].filter(Boolean); + return cleanMultiline(pieces.join('\n'), 3000); +} + 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']); return [...new Set(String(text).toLowerCase().match(/[a-z0-9][a-z0-9-]{3,}/g) || [])].filter(token => !stop.has(token)).slice(0, 8); @@ -735,7 +756,7 @@ function createHandoffContract({ ranked, provenance, decisionContext }) { app.post('/api/rank-feedback', (req, res) => { const idea = cleanMultiline(req.body?.idea || '', 3000); - const context = cleanMultiline(req.body?.context || '', 3000); + const context = cleanContextText(req.body?.context || ''); const modeId = cleanText(req.body?.mode || 'progress', 40); const mode = judgementModes[modeId] || judgementModes.progress; const provenance = cleanProvenance(req.body || {});