Harden Scattermind summary handoff provenance
This commit is contained in:
@@ -49,7 +49,7 @@ 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 `snapshot.context` or `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 artifact 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`. 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.
|
||||
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` / `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 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`.
|
||||
|
||||
|
||||
@@ -649,6 +649,39 @@ try {
|
||||
assert.equal(softLabelLens.handoff.readiness.status, 'ready');
|
||||
assert.deepEqual(softLabelLens.handoff.warnings, []);
|
||||
|
||||
const summaryOnlyConceptMapResponse = await fetch(`${base}/api/rank-feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
reference_code: 'SM-SUMMARY-ONLY',
|
||||
working_name: 'Tired maker continuation pass',
|
||||
opening_reflection: 'A tired maker needs one defended first-week build order after Scattermind clarified the idea.',
|
||||
context: 'Solo builder. Manual proof first. Avoid accounts and saved workspaces before the build order lands.',
|
||||
mode: 'mvp',
|
||||
lenses: {
|
||||
risk: 'Avoid accounts and saved workspaces before the first copyable result works.',
|
||||
channel: 'First week: Manual source-traced preview - turn the Concept Map into one defended next move. Evidence next: Copyable handoff comprehension check - ask three tired users what they would do next. Hold for later: Visual polish after comprehension proof. Set aside: Saved account workspace with dashboard and billing.',
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert.equal(summaryOnlyConceptMapResponse.status, 200);
|
||||
const summaryOnlyConceptMap = await summaryOnlyConceptMapResponse.json();
|
||||
assert.equal(summaryOnlyConceptMap.input.idea, 'A tired maker needs one defended first-week build order after Scattermind clarified the idea.');
|
||||
assert.equal(summaryOnlyConceptMap.input.provenance.artifactId, 'SM-SUMMARY-ONLY');
|
||||
assert.equal(summaryOnlyConceptMap.input.provenance.snapshotTitle, 'Tired maker continuation pass');
|
||||
assert.match(summaryOnlyConceptMap.input.provenance.sourceSummary, /first-week build order/);
|
||||
assert.equal(summaryOnlyConceptMap.input.provenance.originalPrompt, '');
|
||||
assert.equal(summaryOnlyConceptMap.ranked[0].lane.id, 'do');
|
||||
assert.match(summaryOnlyConceptMap.ranked[0].provenance.sourceQuote, /First week: Manual source-traced preview/);
|
||||
assert.equal(summaryOnlyConceptMap.ranked.find(item => item.id === 'build-order-2').lane.id, 'test');
|
||||
assert.equal(summaryOnlyConceptMap.handoff.source.hasOriginalPrompt, false);
|
||||
assert.equal(summaryOnlyConceptMap.handoff.source.hasSourceSummary, true);
|
||||
assert.equal(summaryOnlyConceptMap.handoff.readiness.status, 'ready');
|
||||
assert.equal(summaryOnlyConceptMap.handoff.readiness.sourceComplete, true);
|
||||
assert.deepEqual(summaryOnlyConceptMap.handoff.warnings, []);
|
||||
assert.match(summaryOnlyConceptMap.handoff.copyableText, /Source summary: A tired maker needs one defended first-week build order/);
|
||||
assert.match(summaryOnlyConceptMap.brief.source.sourceSummaryExcerpt, /first-week build order/);
|
||||
|
||||
const softLabelObjectResponse = await fetch(`${base}/api/rank-feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -594,6 +594,7 @@ function cleanProvenance(input = {}) {
|
||||
const conceptMap = objectFrom(input.conceptMap || input.concept_map || featureSet.conceptMap || featureSet.concept_map || artifact.conceptMap || artifact.concept_map);
|
||||
const snapshot = objectFrom(input.snapshot || featureSet.snapshot || artifact.snapshot || conceptMap.snapshot);
|
||||
const source = objectFrom(input.source || featureSet.source || artifact.source);
|
||||
const sourceSummary = input.sourceSummary || input.source_summary || input.opening_reflection || input.restated_idea || artifact.sourceSummary || artifact.source_summary || artifact.opening_reflection || snapshot.sourceSummary || snapshot.source_summary || snapshot.restated_idea || conceptMap.sourceSummary || conceptMap.source_summary || conceptMap.opening_reflection || conceptMap.restated_idea || '';
|
||||
return {
|
||||
schema: cleanText(input.schema || featureSet.schema || artifact.schema || input.type || 'prioritix-feature-set-v1', 80),
|
||||
source: cleanText(input.sourceName || input.source_name || featureSet.sourceName || featureSet.source_name || source.name || artifact.sourceName || artifact.source_name || 'Scattermind', 80),
|
||||
@@ -601,6 +602,7 @@ function cleanProvenance(input = {}) {
|
||||
snapshotTitle: cleanText(input.snapshotTitle || input.snapshot_title || input.working_name || input.workingName || artifact.snapshotTitle || artifact.snapshot_title || snapshot.title || snapshot.name || conceptMap.snapshotTitle || conceptMap.snapshot_title || conceptMap.working_name || conceptMap.workingName || input.ideaTitle || input.idea_title || '', 160),
|
||||
conceptMapId: cleanText(input.conceptMapId || input.concept_map_id || artifact.conceptMapId || artifact.concept_map_id || conceptMap.id || conceptMap.artifactId || conceptMap.artifact_id || input.referenceCode || input.reference_code || conceptMap.referenceCode || conceptMap.reference_code || '', 120),
|
||||
originalPrompt: cleanMultiline(input.originalPrompt || input.original_prompt || input.initialPrompt || input.initial_prompt || input.ideaText || input.idea_text || input.prompt || artifact.originalPrompt || artifact.original_prompt || source.originalPrompt || source.original_prompt || snapshot.originalPrompt || snapshot.original_prompt || snapshot.prompt || conceptMap.originalPrompt || conceptMap.original_prompt || conceptMap.ideaText || conceptMap.idea_text || input.idea || '', 1200),
|
||||
sourceSummary: cleanMultiline(sourceSummary, 1200),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -802,20 +804,20 @@ function normalizeCandidateGroup(group = []) {
|
||||
|
||||
function sentenceFragments(text = '') {
|
||||
return cleanMultiline(text, 4000)
|
||||
.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:')
|
||||
.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:')
|
||||
.split(/\n|;|\s+[•-]\s+/)
|
||||
.map(part => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function titleFromBuildOrderFragment(value = '') {
|
||||
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 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 first = cleaned.split(/\s[-–—:]\s|\.\s/)[0] || cleaned;
|
||||
return cleanText(first, 120);
|
||||
}
|
||||
|
||||
function laneFromBuildOrderLabel(fragment = '') {
|
||||
if (/^(build first|start here|ship first|continue first|make tangible first|make tangible)\s*:/i.test(fragment)) return 'do-first';
|
||||
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';
|
||||
@@ -1152,6 +1154,7 @@ function createDecisionBrief({ idea, context, mode, ranked, provenance, decision
|
||||
snapshotTitle: provenance.snapshotTitle,
|
||||
conceptMapId: provenance.conceptMapId,
|
||||
originalPromptExcerpt: cleanText(provenance.originalPrompt, 260),
|
||||
sourceSummaryExcerpt: cleanText(provenance.sourceSummary, 260),
|
||||
} : null,
|
||||
expertReflections: [
|
||||
{
|
||||
@@ -1226,7 +1229,7 @@ function handoffReadinessFor({ ranked = [], provenance = {}, warnings = [], expe
|
||||
const uniqueWarnings = [...new Set(warnings)];
|
||||
const activeItems = ranked.filter(item => ['do', 'test'].includes(item.lane?.id));
|
||||
const guardrailWarnings = uniqueWarnings.filter(item => /^active item .* conflicts with source non-goals/i.test(item));
|
||||
const sourceWarnings = uniqueWarnings.filter(item => /^missing (source section|original prompt provenance)/i.test(item));
|
||||
const sourceWarnings = uniqueWarnings.filter(item => /^missing (source section|source context provenance)/i.test(item));
|
||||
const evidenceWarnings = uniqueWarnings.filter(item => /^missing evidence needed for active item/i.test(item));
|
||||
const duplicateWarnings = uniqueWarnings.filter(item => /^duplicate source id/i.test(item));
|
||||
const missingArtifact = uniqueWarnings.includes('missing source artifact id');
|
||||
@@ -1238,7 +1241,7 @@ function handoffReadinessFor({ ranked = [], provenance = {}, warnings = [], expe
|
||||
const nextChecks = [];
|
||||
|
||||
if (guardrailWarnings.length) nextChecks.push('Resolve source non-goal conflicts before treating any active item as build-ready.');
|
||||
if (expectsSourceTrace && sourceWarnings.length) nextChecks.push('Attach source section / prompt provenance from the Scattermind artifact.');
|
||||
if (expectsSourceTrace && sourceWarnings.length) nextChecks.push('Attach source section / prompt or summary provenance from the Scattermind artifact.');
|
||||
if (expectsSourceTrace && evidenceWarnings.length) nextChecks.push('Add evidenceNeeded to every Do first / Validate next candidate before handoff.');
|
||||
if (missingArtifact) nextChecks.push(expectsSourceTrace ? 'Attach the Scattermind artifact id so the build order can be traced back.' : 'Attach a source artifact id if this came from Scattermind rather than a plain paste.');
|
||||
if (duplicateWarnings.length) nextChecks.push('Review normalized duplicate IDs before downstream tools store references.');
|
||||
@@ -1272,7 +1275,7 @@ function handoffReadinessFor({ ranked = [], provenance = {}, warnings = [], expe
|
||||
nextChecks: nextChecks.slice(0, 5),
|
||||
activeItemCount: activeItems.length,
|
||||
warningCount: uniqueWarnings.length,
|
||||
sourceComplete: Boolean(!expectsSourceTrace || (provenance?.originalPrompt && activeItems.every(item => item.provenance?.sourceSection))),
|
||||
sourceComplete: Boolean(!expectsSourceTrace || ((provenance?.originalPrompt || provenance?.sourceSummary) && activeItems.every(item => item.provenance?.sourceSection))),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1288,6 +1291,11 @@ function copyableHandoffText({ ranked = [], provenance = {}, decisionContext = {
|
||||
provenance?.snapshotTitle,
|
||||
provenance?.artifactId,
|
||||
].filter(Boolean).join(' · ');
|
||||
const sourceContextLine = provenance?.originalPrompt
|
||||
? `Original prompt: ${cleanText(provenance.originalPrompt, 240)}`
|
||||
: provenance?.sourceSummary
|
||||
? `Source summary: ${cleanText(provenance.sourceSummary, 240)}`
|
||||
: '';
|
||||
const contextLines = [
|
||||
decisionContext?.targetAudience ? `Audience: ${decisionContext.targetAudience}` : '',
|
||||
...(decisionContext?.constraints || []).slice(0, 3).map(item => `Constraint: ${item}`),
|
||||
@@ -1316,7 +1324,7 @@ function copyableHandoffText({ ranked = [], provenance = {}, decisionContext = {
|
||||
return [
|
||||
'# Ranker build-order handoff',
|
||||
sourceLine ? `Source: ${sourceLine}` : '',
|
||||
provenance?.originalPrompt ? `Original prompt: ${cleanText(provenance.originalPrompt, 240)}` : '',
|
||||
sourceContextLine,
|
||||
readiness?.status ? `Readiness: ${readiness.status} — ${readiness.summary || ''}` : '',
|
||||
contextLines.length ? ['## Carried context', ...contextLines.map(item => `- ${item}`)].join('\n') : '',
|
||||
...itemLines,
|
||||
@@ -1328,7 +1336,7 @@ function createHandoffContract({ ranked, provenance, decisionContext }) {
|
||||
const warnings = [];
|
||||
const expectsSourceTrace = Boolean(provenance?.artifactId || provenance?.conceptMapId || provenance?.snapshotTitle);
|
||||
if (!provenance?.artifactId && provenance?.originalPrompt) warnings.push('missing source artifact id');
|
||||
if (expectsSourceTrace && !provenance?.originalPrompt) warnings.push('missing original prompt provenance');
|
||||
if (expectsSourceTrace && !provenance?.originalPrompt && !provenance?.sourceSummary) warnings.push('missing source context provenance');
|
||||
|
||||
const itemTrace = ranked.map(item => {
|
||||
if (expectsSourceTrace && !item.provenance?.sourceSection) warnings.push(`missing source section for ${item.id}`);
|
||||
@@ -1364,7 +1372,9 @@ function createHandoffContract({ ranked, provenance, decisionContext }) {
|
||||
snapshotTitle: provenance?.snapshotTitle || '',
|
||||
conceptMapId: provenance?.conceptMapId || '',
|
||||
originalPromptExcerpt: cleanText(provenance?.originalPrompt || '', 260),
|
||||
sourceSummaryExcerpt: cleanText(provenance?.sourceSummary || '', 260),
|
||||
hasOriginalPrompt: Boolean(provenance?.originalPrompt),
|
||||
hasSourceSummary: Boolean(provenance?.sourceSummary),
|
||||
requiresSourceTrace: expectsSourceTrace,
|
||||
},
|
||||
decisionContext: {
|
||||
@@ -1382,7 +1392,7 @@ function createHandoffContract({ ranked, provenance, decisionContext }) {
|
||||
|
||||
app.post('/api/rank-feedback', (req, res) => {
|
||||
const body = expandEmbeddedRankPayload(req.body || {});
|
||||
const idea = cleanMultiline(body?.idea || '', 3000);
|
||||
const idea = cleanMultiline(body?.idea || body?.opening_reflection || body?.restated_idea || '', 3000);
|
||||
const context = cleanContextText(body?.context || '');
|
||||
const modeId = cleanText(body?.mode || 'progress', 40);
|
||||
const mode = judgementModes[modeId] || judgementModes.progress;
|
||||
|
||||
Reference in New Issue
Block a user