Accept pasted Scattermind concept maps

This commit is contained in:
OpenClaw Bot
2026-05-27 00:19:33 +02:00
parent 771b5e7c02
commit 4b3fb9e7d9
5 changed files with 122 additions and 18 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ Ranker's continuation job is narrow:
Candidate items may include optional 110 `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 items may include optional 110 `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.
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. 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: Recommended payload shape:
+27 -2
View File
@@ -51,6 +51,31 @@ function laneClass(lane) {
return `lane-${lane?.id || 'defer'}`; return `lane-${lane?.id || 'defer'}`;
} }
function parsePastedJsonPayload(value) {
const text = String(value || '').trim();
if (!text.startsWith('{') || !text.endsWith('}')) return null;
try {
const parsed = JSON.parse(text);
const looksLikeBridgePayload = parsed && typeof parsed === 'object' && !Array.isArray(parsed) && (
parsed.schema || parsed.featureSet || parsed.conceptMap || parsed.lenses || parsed.reference_code || parsed.referenceCode || parsed.artifactId || parsed.ideaText
);
return looksLikeBridgePayload ? parsed : null;
} catch {
return null;
}
}
function payloadFromForm(formPayload) {
const ideaJson = parsePastedJsonPayload(formPayload.idea);
const optionsJson = parsePastedJsonPayload(formPayload.optionsText);
const embedded = ideaJson || optionsJson;
if (!embedded) return formPayload;
const merged = { ...embedded };
if (!merged.mode && formPayload.mode) merged.mode = formPayload.mode;
if (String(formPayload.context || '').trim() && !merged.context) merged.context = formPayload.context;
return merged;
}
function renderMetrics(metrics = {}) { function renderMetrics(metrics = {}) {
const items = [ const items = [
['Value', metrics.value], ['Value', metrics.value],
@@ -227,8 +252,8 @@ function renderResults(data) {
async function createFeedbackMap(event) { async function createFeedbackMap(event) {
event.preventDefault(); event.preventDefault();
const submit = form.querySelector('button[type="submit"]'); const submit = form.querySelector('button[type="submit"]');
const payload = Object.fromEntries(new FormData(form).entries()); const payload = payloadFromForm(Object.fromEntries(new FormData(form).entries()));
if (!String(payload.optionsText || '').trim()) payload.optionsText = payload.idea; if (!String(payload.optionsText || '').trim() && !payload.conceptMap && !payload.featureSet && !payload.lenses) payload.optionsText = payload.idea;
submit.disabled = true; submit.disabled = true;
submit.textContent = 'Judging…'; submit.textContent = 'Judging…';
try { try {
+5 -3
View File
@@ -59,13 +59,15 @@
<div class="tool-intro"> <div class="tool-intro">
<p class="eyebrow">MVP · feedback front door · no account · no dashboard swamp</p> <p class="eyebrow">MVP · feedback front door · no account · no dashboard swamp</p>
<h2 id="try-title">Send a decision brief to the room</h2> <h2 id="try-title">Send a decision brief to the room</h2>
<p>Paste the backlog, rough notes, feature dump, or possible next moves. Ranker will extract candidates when it can; use the optional candidate box only if you already have a cleaner list.</p> <p>Paste the backlog, rough notes, feature dump, possible next moves, or a raw Scattermind Concept Map JSON export. Ranker will extract candidates when it can; use the optional candidate box only if you already have a cleaner list.</p>
</div> </div>
<form class="rank-form" id="rankForm"> <form class="rank-form" id="rankForm">
<label> <label>
<span>Paste the messy backlog / idea dump <b>required</b></span> <span>Paste the messy backlog / idea dump / Concept Map JSON <b>required</b></span>
<textarea name="idea" rows="8" required placeholder="Example: Im building a tool that helps freelancers package their services and decide what to sell first. Maybe offer critique, pricing calculator, proposal generator, landing page copywriter, client persona mapper, and some kind of dashboard later? I only have a week and want the fastest useful proof."></textarea> <textarea name="idea" rows="8" required placeholder="Example: Im building a tool that helps freelancers package their services and decide what to sell first. Maybe offer critique, pricing calculator, proposal generator, landing page copywriter, client persona mapper, and some kind of dashboard later? I only have a week and want the fastest useful proof.
Or paste a Scattermind Concept Map JSON object here; Ranker will preserve source provenance and extract the build order."></textarea>
</label> </label>
<label> <label>
+28 -1
View File
@@ -506,7 +506,34 @@ try {
assert.equal(mergedContext.ranked.find(item => item.id === 'workspace-dashboard').lane.source, 'source-non-goal'); assert.equal(mergedContext.ranked.find(item => item.id === 'workspace-dashboard').lane.source, 'source-non-goal');
assert.deepEqual(mergedContext.handoff.warnings, []); assert.deepEqual(mergedContext.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, duplicateIds: duplicateIds.ranked.map(item => item.id), provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2)); const embeddedJsonResponse = await fetch(`${base}/api/rank-feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
idea: JSON.stringify({
reference_code: 'SM-PASTE1',
working_name: 'Raw pasted Concept Map',
ideaText: 'The user pasted a raw Scattermind Concept Map JSON blob into the public Ranker form.',
lenses: {
risk: 'Avoid saved workspaces before one copyable result lands.',
channel: 'Build first: Raw JSON handoff detection - turn the pasted Concept Map into a defended build order. Test manually: Copy the decision brief into notes and check whether the first move survives. Probably noise: Saved workspace with accounts and team dashboard.',
},
}),
mode: 'mvp',
context: '',
}),
});
assert.equal(embeddedJsonResponse.status, 200);
const embeddedJson = await embeddedJsonResponse.json();
assert.equal(embeddedJson.input.embeddedPayloadSource, 'idea');
assert.equal(embeddedJson.input.provenance.artifactId, 'SM-PASTE1');
assert.equal(embeddedJson.input.optionCount, 3);
assert.equal(embeddedJson.ranked[0].id, 'build-order-1');
assert.equal(embeddedJson.ranked.find(item => item.id === 'build-order-3').lane.id, 'park');
assert.ok(embeddedJson.input.decisionContext.nonGoals.includes('Avoid saved workspaces before one copyable result lands'));
assert.deepEqual(embeddedJson.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, duplicateIds: duplicateIds.ranked.map(item => item.id), provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2));
} finally { } finally {
server.kill('SIGTERM'); server.kill('SIGTERM');
} }
+61 -11
View File
@@ -468,6 +468,55 @@ function objectFrom(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
} }
function looksLikeRankPayload(value = {}) {
return Boolean(
value.schema
|| value.featureSet
|| value.conceptMap
|| value.lenses
|| value.reference_code
|| value.referenceCode
|| value.artifactId
|| value.sourceArtifactId
|| value.ideaText
|| Array.isArray(value.features)
|| Array.isArray(value.actions)
|| Array.isArray(value.nextMoves)
|| Array.isArray(value.candidates)
);
}
function parseEmbeddedRankPayload(value = '') {
const text = cleanMultiline(value, 12000);
if (!text.startsWith('{') || !text.endsWith('}')) return null;
try {
const parsed = JSON.parse(text);
return looksLikeRankPayload(parsed) ? parsed : null;
} catch {
return null;
}
}
function expandEmbeddedRankPayload(body = {}) {
const original = objectFrom(body);
for (const key of ['payload', 'rankPayload', 'scattermindPayload', 'conceptMapJson', 'idea', 'ideaText', 'optionsText']) {
const embedded = typeof original[key] === 'string'
? parseEmbeddedRankPayload(original[key])
: looksLikeRankPayload(original[key])
? original[key]
: null;
if (!embedded) continue;
const expanded = { ...original, ...embedded };
if (key === 'idea' && !embedded.idea && !embedded.ideaText) expanded.idea = '';
if (key === 'optionsText' && !embedded.optionsText) expanded.optionsText = '';
if (original.mode && !embedded.mode) expanded.mode = original.mode;
if (original.context && !embedded.context) expanded.context = original.context;
expanded._embeddedPayloadSource = key;
return expanded;
}
return original;
}
function cleanProvenance(input = {}) { function cleanProvenance(input = {}) {
const featureSet = objectFrom(input.featureSet); const featureSet = objectFrom(input.featureSet);
const artifact = objectFrom(input.artifact || featureSet.artifact); const artifact = objectFrom(input.artifact || featureSet.artifact);
@@ -519,10 +568,10 @@ function cleanDecisionContext(input = {}) {
const artifact = objectFrom(input.artifact || featureSet.artifact); const artifact = objectFrom(input.artifact || featureSet.artifact);
const conceptMap = objectFrom(input.conceptMap || featureSet.conceptMap || artifact.conceptMap); const conceptMap = objectFrom(input.conceptMap || featureSet.conceptMap || artifact.conceptMap);
const conceptMapLenses = objectFrom(conceptMap.lenses || input.lenses || featureSet.lenses); const conceptMapLenses = objectFrom(conceptMap.lenses || input.lenses || featureSet.lenses);
const riskLens = objectFrom(conceptMapLenses.risk || conceptMapLenses.risks || conceptMapLenses.boundaries || conceptMapLenses.notYet); const riskLens = conceptMapLenses.risk || conceptMapLenses.risks || conceptMapLenses.boundaries || conceptMapLenses.notYet;
const audienceLens = objectFrom(conceptMapLenses.audience || conceptMapLenses.who || conceptMapLenses.customer || conceptMapLenses.users); const audienceLens = conceptMapLenses.audience || conceptMapLenses.who || conceptMapLenses.customer || conceptMapLenses.users;
const constraintsLens = objectFrom(conceptMapLenses.constraints || conceptMapLenses.boundaries || conceptMapLenses.scope); const constraintsLens = conceptMapLenses.constraints || conceptMapLenses.boundaries || conceptMapLenses.scope;
const assumptionsLens = objectFrom(conceptMapLenses.assumptions || conceptMapLenses.unknowns || conceptMapLenses.openQuestions); const assumptionsLens = conceptMapLenses.assumptions || conceptMapLenses.unknowns || conceptMapLenses.openQuestions;
const structuredContext = objectFrom(input.context); const structuredContext = objectFrom(input.context);
const contextSources = [ const contextSources = [
input.decisionContext, input.decisionContext,
@@ -1050,13 +1099,14 @@ function createHandoffContract({ ranked, provenance, decisionContext }) {
} }
app.post('/api/rank-feedback', (req, res) => { app.post('/api/rank-feedback', (req, res) => {
const idea = cleanMultiline(req.body?.idea || '', 3000); const body = expandEmbeddedRankPayload(req.body || {});
const context = cleanContextText(req.body?.context || ''); const idea = cleanMultiline(body?.idea || '', 3000);
const modeId = cleanText(req.body?.mode || 'progress', 40); const context = cleanContextText(body?.context || '');
const modeId = cleanText(body?.mode || 'progress', 40);
const mode = judgementModes[modeId] || judgementModes.progress; const mode = judgementModes[modeId] || judgementModes.progress;
const provenance = cleanProvenance(req.body || {}); const provenance = cleanProvenance(body || {});
const decisionContext = cleanDecisionContext(req.body || {}); const decisionContext = cleanDecisionContext(body || {});
let options = optionsFromBody(req.body || {}); let options = optionsFromBody(body || {});
if (options.length < 2) return res.status(400).json({ error: 'Paste at least two options, features, ideas, or next moves to rank.' }); if (options.length < 2) return res.status(400).json({ error: 'Paste at least two options, features, ideas, or next moves to rank.' });
const bridgeContext = `${idea}\n${context}\n${provenance.snapshotTitle}\n${provenance.schema}\n${decisionContext.targetAudience}\n${decisionContext.constraints.join('\n')}\n${decisionContext.assumptions.join('\n')}`; const bridgeContext = `${idea}\n${context}\n${provenance.snapshotTitle}\n${provenance.schema}\n${decisionContext.targetAudience}\n${decisionContext.constraints.join('\n')}\n${decisionContext.assumptions.join('\n')}`;
options = options.map(option => ({ ...option, metrics: scoreOption(option, mode, bridgeContext, decisionContext) })) options = options.map(option => ({ ...option, metrics: scoreOption(option, mode, bridgeContext, decisionContext) }))
@@ -1081,7 +1131,7 @@ app.post('/api/rank-feedback', (req, res) => {
res.json({ res.json({
ok: true, ok: true,
mode: { id: modeId in judgementModes ? modeId : 'progress', label: mode.label }, mode: { id: modeId in judgementModes ? modeId : 'progress', label: mode.label },
input: { idea, context, optionCount: options.length, provenance, decisionContext }, input: { idea, context, optionCount: options.length, provenance, decisionContext, embeddedPayloadSource: body._embeddedPayloadSource || '' },
ranked: options, ranked: options,
brief, brief,
rankConfidence: rankConfidenceFor(options), rankConfidence: rankConfidenceFor(options),