diff --git a/README.md b/README.md index 6098d6b..a54155d 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ Ranker's continuation job is narrow: 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. +Candidate trace note: candidate-level `sourceItemId` / `traceId`, `sourceTitle` / `lensTitle`, and `sourceExcerpt` / `sourceQuote` are preserved in ranked items, `buildOrderDetails`, and `handoff.itemTrace`. 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. + 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. 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 19edd25..6a8ea4a 100644 --- a/scripts/check-rank-feedback.mjs +++ b/scripts/check-rank-feedback.mjs @@ -638,7 +638,37 @@ try { assert.ok(fencedJson.input.decisionContext.nonGoals.includes('Avoid saved workspaces and account dashboard before the first manual proof')); assert.deepEqual(fencedJson.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, scattermindPaidShapeTop: scattermindPaidShape.ranked[0].id, mergedContextTop: mergedContext.ranked[0].id, embeddedJsonTop: embeddedJson.ranked[0].id, fencedJsonTop: fencedJson.ranked[0].id, duplicateIds: duplicateIds.ranked.map(item => item.id), provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2)); + const sourceExcerptResponse = await fetch(`${base}/api/rank-feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceName: 'Scattermind', + artifactId: 'concept_map_source_excerpts', + originalPrompt: 'Scattermind exported source excerpts for each recommended move.', + idea: 'Ranker should preserve source excerpts so the defended build order can point back to the exact Concept Map note.', + mode: 'mvp', + conceptMap: { + nextActions: [ + { id: 'manual-proof', action: 'Manual proof preview', why: 'Show one defended next move before adding machinery.', evidence: 'Can one tired user act on it?', sourceItemId: 'lens-channel-1', sourceTitle: 'Build Order', sourceExcerpt: 'Build first: show one defended next move before adding machinery.', suggestedLane: 'do-first', rankerHints: { value: 9, effort: 2, confidence: 8, urgency: 8, risk: 2 } }, + { id: 'copyable-brief', action: 'Copyable brief', why: 'Let the user paste the defended order into notes.', evidence: 'Does the pasted brief preserve the first move?', sourceItemId: 'lens-channel-2', lensTitle: 'Build Order', quote: 'Test manually: copy the ranked brief into notes.', suggestedLane: 'validate-next' }, + { id: 'saved-workspace', action: 'Saved workspace', why: 'Accounts and saved projects for every idea.', evidence: 'No bridge proof yet.', sourceItemId: 'lens-risk-1', sourceHeading: 'What can mislead you', sourceQuote: 'Probably noise: saved workspace with accounts before proof.', suggestedLane: 'park' }, + ], + }, + }), + }); + assert.equal(sourceExcerptResponse.status, 200); + const sourceExcerpt = await sourceExcerptResponse.json(); + assert.equal(sourceExcerpt.ranked[0].id, 'manual-proof'); + assert.equal(sourceExcerpt.ranked[0].provenance.sourceId, 'lens-channel-1'); + assert.equal(sourceExcerpt.ranked[0].provenance.sourceTitle, 'Build Order'); + assert.match(sourceExcerpt.ranked[0].provenance.sourceQuote, /Build first/); + assert.equal(sourceExcerpt.buildOrderDetails.doFirst[0].sourceId, 'lens-channel-1'); + assert.match(sourceExcerpt.buildOrderDetails.doFirst[0].sourceQuote, /defended next move/); + assert.equal(sourceExcerpt.handoff.itemTrace.find(item => item.id === 'copyable-brief').sourceTitle, 'Build Order'); + assert.match(sourceExcerpt.handoff.itemTrace.find(item => item.id === 'saved-workspace').sourceQuote, /Probably noise/); + assert.deepEqual(sourceExcerpt.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, scattermindPaidShapeTop: scattermindPaidShape.ranked[0].id, mergedContextTop: mergedContext.ranked[0].id, embeddedJsonTop: embeddedJson.ranked[0].id, fencedJsonTop: fencedJson.ranked[0].id, sourceExcerptTop: sourceExcerpt.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 3dcb8fe..ddc8cb1 100644 --- a/server.js +++ b/server.js @@ -687,6 +687,9 @@ function normalizeFeatureOption(item, index, fallbackId = 'feature', defaultSour const rawLane = cleanText(raw.lane || '', 40); const laneLooksLikeHint = Boolean(normalizeLaneHint(rawLane)); const sourceSection = cleanText(raw.sourceSection || raw.section || raw.origin || (!laneLooksLikeHint ? rawLane : '') || defaultSourceSection, 80); + const sourceId = cleanText(raw.sourceId || raw.sourceArtifactId || raw.sourceItemId || raw.traceId || raw.id || '', 120); + const sourceTitle = cleanText(raw.sourceTitle || raw.sourceHeading || raw.lensTitle || raw.heading || '', 140); + const sourceQuote = cleanMultiline(raw.sourceQuote || raw.sourceExcerpt || raw.evidenceQuote || raw.quote || raw.originalText || raw.rawText || '', 420); const recommendedLane = cleanText(raw.recommendedLane || raw.laneHint || raw.suggestedLane || (laneLooksLikeHint ? rawLane : '') || defaultRecommendedLane || '', 40).toLowerCase(); const descriptionParts = [ raw.description || raw.brief || '', @@ -705,8 +708,10 @@ function normalizeFeatureOption(item, index, fallbackId = 'feature', defaultSour description: cleanText(descriptionParts.join(' '), 760), factors: { userValue, evidenceNeeded, risk, nextStep, successSignal, killSignal, proofSteps, dependencies, recommendedLane, metricHints: cleanMetricHints(raw) }, provenance: { - sourceId: cleanText(raw.sourceId || raw.sourceArtifactId || raw.id || '', 120), + sourceId, sourceSection, + sourceTitle, + sourceQuote, }, }; } @@ -1110,6 +1115,8 @@ function compactBuildItems(items = []) { concern: item.concern, sourceSection: item.provenance?.sourceSection || '', sourceId: item.provenance?.sourceId || '', + sourceTitle: item.provenance?.sourceTitle || '', + sourceQuote: item.provenance?.sourceQuote || '', laneSource: item.lane?.source || 'ranked', score: item.metrics?.score ?? null, confidence: item.metrics?.confidence ?? null, @@ -1133,6 +1140,8 @@ function createHandoffContract({ ranked, provenance, decisionContext }) { lane: item.lane?.id || 'defer', sourceSection: item.provenance?.sourceSection || '', sourceId: item.provenance?.sourceId || '', + sourceTitle: item.provenance?.sourceTitle || '', + sourceQuote: item.provenance?.sourceQuote || '', originalId: item.provenance?.originalId || '', idNormalized: Boolean(item.provenance?.idNormalized), evidenceNeeded: item.factors?.evidenceNeeded || '',