diff --git a/README.md b/README.md index 78ebdac..07f0a40 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ 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. Ranker also accepts the current Scattermind storage-row shape with `referenceCode`, `ideaText`, `context`, and string-valued `fullReadingJson` / `full_reading_json`; it expands the saved paid Concept Map before ranking so operators do not have to hand-copy lenses out of Appwrite rows. If the row only has free Snapshot data (`glimpseJson` / `glimpse_json` / `snapshotJson`), Ranker expands that Snapshot into a minimal continuation order: one manual proof plus the first evidence question, with the Snapshot reference code/title preserved for provenance. The public paste form mirrors this: a prose-wrapped/fenced Appwrite row paste stays intact for the API instead of unwrapping the stringified reading into nonsense fields. The decision `brief.quickGlance.sourceTrace` now repeats the winning item's source section/id/title/quote, and both `brief.source.originalPromptExcerpt` / `handoff.source.originalPromptExcerpt` or, when the original prompt is unavailable, `sourceSummaryExcerpt` carry the source context 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 and direct bridge/envelope sections 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` or candidate source trace. Ranker also accepts softer continuation envelopes named `rankerBridge`, `continuation`, or `continuationPlan`, candidate arrays named `possibleNextMoves`, `suggestedNextMoves`, `recommendations`, or `opportunities`, and laned `buildOrderPreview` / `build_order_preview` objects so Scattermind can pass a paid Concept Map preview without renaming it into software-feature language. +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`, with either colon or reader-friendly dash separators (`Continue first: …`, `Continue first — …`, `Evidence next - …`). Build Order objects and direct bridge/envelope sections 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` or candidate source trace. Ranker also accepts softer continuation envelopes named `rankerBridge`, `continuation`, or `continuationPlan`, candidate arrays named `possibleNextMoves`, `suggestedNextMoves`, `recommendations`, or `opportunities`, and laned `buildOrderPreview` / `build_order_preview` objects so Scattermind can pass a paid Concept Map preview without renaming it into software-feature language. 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; soft guardrail language such as “this is not a dashboard” or “keep auth/billing/workspaces out until proof” is promoted into non-goals, not merely background context. 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. Handoff `activeSlice` (`ranker-active-slice-v1`) is the compact machine-readable continuation unit: one active item, its proof/evidence/success/kill signals, source anchor, held-back items, readiness status, and the rule that only this slice is build-ready. For tired first-screen users, `brief.decisionReceipt` repeats the one active move, first proof step, evidence question, held-back items, source anchor, and the handoff rule that only Do first is active; use it as the compact result strip before showing the full lane board. 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. Exact free Snapshot JSON (`working_name`, `restated_idea`, `lenses.shape`, `questions_to_sit_with`, `reference_code`) is rankable too: Ranker derives a manual proof active slice plus evidence questions, carrying the Snapshot reference code/title into provenance so a Snapshot-only handoff does not need a paid Concept Map before it can produce a useful build order. If a Concept Map only carries `questions_to_sit_with` / `questionsToSitWith` / `openQuestions` and no explicit build-order lanes or action threads, Ranker converts those questions into Validate-next evidence actions with source trace instead of pretending they are software features. diff --git a/scripts/check-rank-feedback.mjs b/scripts/check-rank-feedback.mjs index ea2955c..23985e0 100644 --- a/scripts/check-rank-feedback.mjs +++ b/scripts/check-rank-feedback.mjs @@ -230,6 +230,36 @@ try { assert.equal(threadGuardrail.buildOrder.doFirst[0], 'build-order-2', 'thread-level guardrail should demote a labelled Build first dashboard and promote the first eligible manual move'); assert.match(threadGuardrail.brief.decisionReceipt.sourceAnchor, /concept-map\.lenses\.channel/); + const softDashLabelResponse = await fetch(`${base}/api/rank-feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceName: 'Scattermind', + artifactId: 'concept_map_soft_dash_labels', + originalPrompt: 'Scattermind used reader-friendly continuation labels with dashes instead of colon-prefixed lanes.', + context: 'Solo builder. Avoid account dashboards and saved workspaces before a manual proof.', + conceptMap: { + working_name: 'Reader-friendly continuation labels', + lenses: { + channel: { + title: 'Build Order Preview', + content: 'Continue first — Manual Concept Map preview with one defended first move. Evidence next – Copy the handoff and ask one tired user what they would do. Hold for later - Polished export templates after proof. Set aside — Saved workspace dashboard with auth and collaboration.', + }, + }, + }, + }), + }); + assert.equal(softDashLabelResponse.status, 200); + const softDashLabel = await softDashLabelResponse.json(); + assert.equal(softDashLabel.input.optionCount, 4); + assert.equal(softDashLabel.buildOrder.doFirst[0], 'build-order-1'); + assert.equal(softDashLabel.ranked.find(item => item.id === 'build-order-1').title, 'Manual Concept Map preview with one defended first move'); + assert.equal(softDashLabel.ranked.find(item => item.id === 'build-order-2').lane.id, 'test'); + assert.equal(softDashLabel.ranked.find(item => item.id === 'build-order-3').lane.id, 'defer'); + assert.equal(softDashLabel.ranked.find(item => item.id === 'build-order-4').lane.id, 'park'); + assert.match(softDashLabel.handoff.itemTrace.find(item => item.id === 'build-order-1').sourceQuote, /Continue first/); + assert.deepEqual(softDashLabel.handoff.warnings, []); + const hintedResponse = await fetch(`${base}/api/rank-feedback`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/server.js b/server.js index 91673f1..bb5bb96 100644 --- a/server.js +++ b/server.js @@ -1007,25 +1007,29 @@ function normalizeCandidateGroup(group = []) { return normalizeOptionIds(options); } +const buildOrderLabelSeparator = '\\s*(?::|[-–—])\\s*'; +const buildOrderLabelPattern = '(build first|start here|ship first|first week|week one|first-week build order|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)'; +const buildOrderLabelRegex = new RegExp(`^${buildOrderLabelPattern}${buildOrderLabelSeparator}`, 'i'); + function sentenceFragments(text = '') { return cleanMultiline(text, 4000) - .replace(/\s+(build first|start here|ship first|first week|week one|first-week build order|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:') + .replace(new RegExp(`\\s+${buildOrderLabelPattern}${buildOrderLabelSeparator}`, '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|first week|week one|first-week build order|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 cleaned = cleanText(value.replace(buildOrderLabelRegex, ''), 220); const first = cleaned.split(/\s[-–—:]\s|\.\s/)[0] || cleaned; - return cleanText(first, 120); + return cleanText(first.replace(/[.!?]+$/g, ''), 120); } function laneFromBuildOrderLabel(fragment = '') { - if (/^(build first|start here|ship first|first week|week one|first-week build order|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'; + if (new RegExp(`^(build first|start here|ship first|first week|week one|first-week build order|continue first|make tangible first|make tangible)${buildOrderLabelSeparator}`, 'i').test(fragment)) return 'do-first'; + if (new RegExp(`^(try next|evidence next|learn next|test manually|validate next)${buildOrderLabelSeparator}`, 'i').test(fragment)) return 'validate-next'; + if (new RegExp(`^(hold for later|not yet|defer|do not build yet|don't build yet)${buildOrderLabelSeparator}`, 'i').test(fragment)) return 'defer'; + if (new RegExp(`^(set aside|out of scope|probably noise|park)${buildOrderLabelSeparator}`, 'i').test(fragment)) return 'park'; return ''; }