diff --git a/README.md b/README.md index 68a9bbd..21f6bfd 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ Candidate items may include optional 1–10 `rankerHints` (`value`, `effort`, `c Candidate trace note: candidate-level `sourceItemId` / `traceId`, `sourceTitle` / `lensTitle`, and `sourceExcerpt` / `sourceQuote` are preserved in ranked items, `buildOrderDetails`, and `handoff.itemTrace`. Lens-only Build Order text is also split into deterministic `concept-map.lenses.channel#N` source IDs with the original labelled sentence carried as `sourceQuote`, so pasted paid Concept Maps remain traceable even without explicit candidate objects. String items in laned Build Order arrays now also receive deterministic section-local source IDs such as `concept-map.buildOrder.validateNext#1` and carry the original string as `sourceQuote`, so simple Scattermind exports stay addressable downstream instead of becoming anonymous `feature-1` rows. The decision `brief.quickGlance.sourceTrace` now repeats the winning item's source section/id/title/quote, and both `brief.source.originalPromptExcerpt` and `handoff.source.originalPromptExcerpt` carry a short prompt excerpt so a downstream Scattermind handoff can show why the build order exists without digging through `input.provenance`. Scattermind should use these when a next move came from a specific Concept Map lens sentence, so Ranker can defend not just what wins but where the judgement came from. +Soft Scattermind labels are accepted at the bridge boundary so Scattermind does not need to use harsh verdict copy in its own product surface. Lens text can say `Continue first`, `Make tangible`, `Try next`, `Evidence next`, `Hold for later`, or `Set aside`; Build Order objects can use matching camel/snake-case keys such as `continueFirst`, `evidenceNext`, `holdForLater`, and `setAside`. Ranker maps those to `doFirst / validateNext / defer / park` while preserving the softer original label in `sourceQuote`. + 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. Handoff `source.requiresSourceTrace` is true only when a real source artifact/title is present; plain idea-only ranking still warns about a missing artifact ID when it carries prompt provenance, but it does not spam source-section/evidence warnings meant for Scattermind artifacts. Handoff `readiness` now gives downstream bridge consumers a deterministic gate: `ready`, `usable-with-warnings`, `needs-source-context`, or `blocked`, with blockers and next checks for missing evidence, source trace, duplicate IDs, or active source-non-goal conflicts. For low-friction handoff, `/api/rank-feedback` also detects a raw Scattermind/Concept Map JSON object pasted into `idea`, `ideaText`, `optionsText`, or wrapper keys such as `payload`; it expands that object before ranking and reports `input.embeddedPayloadSource` so the public form can accept copy/paste exports without a custom import screen. Recommended payload shape: diff --git a/scripts/check-rank-feedback.mjs b/scripts/check-rank-feedback.mjs index be443d3..0acc03e 100644 --- a/scripts/check-rank-feedback.mjs +++ b/scripts/check-rank-feedback.mjs @@ -618,6 +618,60 @@ try { assert.ok(scattermindPaidShape.ranked.find(item => item.id === 'build-order-4').metrics.nonGoalConflicts.length >= 1); assert.deepEqual(scattermindPaidShape.handoff.warnings, []); + const softLabelLensResponse = await fetch(`${base}/api/rank-feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + reference_code: 'SM-SOFT-LABELS', + working_name: 'Gentle Scattermind continuation labels', + ideaText: 'Scattermind avoids verdict language but still needs to hand Ranker a continuation order.', + context: 'Solo builder. Manual proof before machinery. Avoid accounts and saved workspaces.', + mode: 'mvp', + lenses: { + risk: 'Avoid accounts and saved workspaces until one copyable result makes sense.', + channel: 'Continue first: One source-traced build order preview - make the next move tangible without a dashboard. Try next: Copyable decision brief - ask three tired users what they would do next. Hold for later: Visual polish after the rough brief works. Set aside: Saved workspace with accounts, auth, billing, and collaboration.', + }, + }), + }); + assert.equal(softLabelLensResponse.status, 200); + const softLabelLens = await softLabelLensResponse.json(); + assert.equal(softLabelLens.input.optionCount, 4); + assert.equal(softLabelLens.ranked[0].id, 'build-order-1'); + assert.equal(softLabelLens.ranked[0].lane.id, 'do'); + assert.match(softLabelLens.ranked[0].provenance.sourceQuote, /Continue first: One source-traced build order preview/); + assert.equal(softLabelLens.ranked.find(item => item.id === 'build-order-2').lane.id, 'test'); + assert.equal(softLabelLens.ranked.find(item => item.id === 'build-order-3').lane.id, 'defer'); + assert.equal(softLabelLens.ranked.find(item => item.id === 'build-order-4').lane.id, 'park'); + assert.equal(softLabelLens.handoff.readiness.status, 'ready'); + assert.deepEqual(softLabelLens.handoff.warnings, []); + + const softLabelObjectResponse = await fetch(`${base}/api/rank-feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceName: 'Scattermind', + artifactId: 'concept_map_soft_object_labels', + originalPrompt: 'Scattermind exported soft continuation sections instead of verdict-named lanes.', + idea: 'Ranker should accept softer object keys without forcing Scattermind to say build/kill.', + mode: 'mvp', + conceptMap: { + buildOrder: { + continueFirst: [{ id: 'manual-preview', move: 'Manual continuation preview', evidenceQuestion: 'Can one user explain the next move?' }], + evidenceNext: ['Copyable brief comprehension check'], + holdForLater: ['Visual system polish'], + setAside: ['Account workspace dashboard'], + }, + }, + }), + }); + assert.equal(softLabelObjectResponse.status, 200); + const softLabelObject = await softLabelObjectResponse.json(); + assert.equal(softLabelObject.input.optionCount, 4); + assert.equal(softLabelObject.buildOrder.doFirst[0], 'manual-preview'); + assert.equal(softLabelObject.ranked.find(item => item.id === 'feature-1').lane.id, 'test'); + assert.equal(softLabelObject.ranked.find(item => item.title === 'Visual system polish').lane.id, 'defer'); + assert.equal(softLabelObject.ranked.find(item => item.title === 'Account workspace dashboard').lane.id, 'park'); + const mergedContextResponse = await fetch(`${base}/api/rank-feedback`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/server.js b/server.js index 544d5cc..29b231c 100644 --- a/server.js +++ b/server.js @@ -682,7 +682,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', 'manual', 'value', 'layer']); + const stop = new Set(['the', 'and', 'with', 'from', 'that', 'this', 'into', 'before', 'after', 'until', 'user', 'users', 'idea', 'ideas', 'build', 'first', 'avoid', 'without', 'more', 'full', 'proof', 'manual', 'value', 'layer', 'result', 'sense', 'copyable']); return [...new Set(String(text).toLowerCase().match(/[a-z0-9][a-z0-9-]{3,}/g) || [])].filter(token => !stop.has(token)).slice(0, 8); } @@ -769,10 +769,10 @@ function compactCandidateGroup(group = []) { function buildOrderSectionGroup(buildOrder = {}, baseSection = 'buildOrder') { const source = objectFrom(buildOrder); return compactCandidateGroup([ - { items: source.doFirst || source.do_first || source.buildFirst || source.buildNow || source.now, sourceSection: `${baseSection}.doFirst`, defaultLane: 'do-first' }, - { items: source.validateNext || source.validate_next || source.testNext || source.testManually || source.validation, sourceSection: `${baseSection}.validateNext`, defaultLane: 'validate-next' }, - { items: source.defer || source.deferred || source.later || source.afterProof, sourceSection: `${baseSection}.defer`, defaultLane: 'defer' }, - { items: source.park || source.parkingLot || source.parking_lot || source.parked || source.probablyNoise || source.probably_noise || source.noise, sourceSection: `${baseSection}.park`, defaultLane: 'park' }, + { items: source.doFirst || source.do_first || source.buildFirst || source.buildNow || source.now || source.continueFirst || source.continue_first || source.makeTangible || source.make_tangible || source.startHere || source.start_here, sourceSection: `${baseSection}.doFirst`, defaultLane: 'do-first' }, + { items: source.validateNext || source.validate_next || source.testNext || source.testManually || source.validation || source.evidenceNext || source.evidence_next || source.tryNext || source.try_next || source.learnNext || source.learn_next, sourceSection: `${baseSection}.validateNext`, defaultLane: 'validate-next' }, + { items: source.defer || source.deferred || source.later || source.afterProof || source.holdForLater || source.hold_for_later || source.notYet || source.not_yet, sourceSection: `${baseSection}.defer`, defaultLane: 'defer' }, + { items: source.park || source.parkingLot || source.parking_lot || source.parked || source.probablyNoise || source.probably_noise || source.noise || source.setAside || source.set_aside || source.outOfScope || source.out_of_scope, sourceSection: `${baseSection}.park`, defaultLane: 'park' }, ]); } @@ -786,23 +786,23 @@ function normalizeCandidateGroup(group = []) { 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:') + .replace(/\s+(build first|start here|ship first|continue first|make tangible first|make tangible|try next|evidence next|learn next|test manually|validate next|hold for later|not yet|defer|set aside|out of scope|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 cleaned = cleanText(value.replace(/^(build first|start here|ship first|continue first|make tangible first|make tangible|try next|evidence next|learn next|test manually|validate next|hold for later|not yet|defer|set aside|out of scope|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'; + if (/^(build first|start here|ship first|continue first|make tangible first|make tangible)\s*:/i.test(fragment)) return 'do-first'; + if (/^(try next|evidence next|learn next|test manually|validate next)\s*:/i.test(fragment)) return 'validate-next'; + if (/^(hold for later|not yet|defer|do not build yet|don't build yet)\s*:/i.test(fragment)) return 'defer'; + if (/^(set aside|out of scope|probably noise|park)\s*:/i.test(fragment)) return 'park'; return ''; } @@ -815,7 +815,13 @@ function optionsFromBuildOrderText(text = '', sourceSection = 'concept-map.lense 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.' : ''), + evidence: /test|validate|proof|prove|signal|ask|show/i.test(fragment) + ? fragment + : lane === 'do-first' + ? 'Prove this first move manually before adding product machinery.' + : lane === 'validate-next' + ? 'Collect the smallest real signal before promoting this into the build lane.' + : '', suggestedLane: lane, rankerHints: lane === 'do-first' ? { value: 8, effort: 2, confidence: 7, urgency: 7, risk: 2 } @@ -965,10 +971,10 @@ function scoreOption(option, mode, context = '', decisionContext = {}) { function normalizeLaneHint(value = '') { const hint = cleanText(value, 40).toLowerCase().replace(/_/g, '-'); - if (/^(do|do-first|first|build|build-now|now)$/.test(hint)) return 'do'; - if (/^(validate|validate-next|test|test-next|proof|evidence)$/.test(hint)) return 'test'; - if (/^(defer|later|sequence-later|after-proof)$/.test(hint)) return 'defer'; - if (/^(park|cut|drop|icebox|not-now)$/.test(hint)) return 'park'; + if (/^(do|do-first|first|build|build-now|now|continue-first|make-tangible|start-here)$/.test(hint)) return 'do'; + if (/^(validate|validate-next|test|test-next|proof|evidence|evidence-next|try-next|learn-next)$/.test(hint)) return 'test'; + if (/^(defer|later|sequence-later|after-proof|hold-for-later|not-yet)$/.test(hint)) return 'defer'; + if (/^(park|cut|drop|icebox|not-now|set-aside|out-of-scope)$/.test(hint)) return 'park'; return ''; }