Accept pasted Scattermind concept maps
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 `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:
|
||||
|
||||
|
||||
+27
-2
@@ -51,6 +51,31 @@ function laneClass(lane) {
|
||||
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 = {}) {
|
||||
const items = [
|
||||
['Value', metrics.value],
|
||||
@@ -227,8 +252,8 @@ function renderResults(data) {
|
||||
async function createFeedbackMap(event) {
|
||||
event.preventDefault();
|
||||
const submit = form.querySelector('button[type="submit"]');
|
||||
const payload = Object.fromEntries(new FormData(form).entries());
|
||||
if (!String(payload.optionsText || '').trim()) payload.optionsText = payload.idea;
|
||||
const payload = payloadFromForm(Object.fromEntries(new FormData(form).entries()));
|
||||
if (!String(payload.optionsText || '').trim() && !payload.conceptMap && !payload.featureSet && !payload.lenses) payload.optionsText = payload.idea;
|
||||
submit.disabled = true;
|
||||
submit.textContent = 'Judging…';
|
||||
try {
|
||||
|
||||
+5
-3
@@ -59,13 +59,15 @@
|
||||
<div class="tool-intro">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<form class="rank-form" id="rankForm">
|
||||
<label>
|
||||
<span>Paste the messy backlog / idea dump <b>required</b></span>
|
||||
<textarea name="idea" rows="8" required placeholder="Example: I’m 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>
|
||||
<span>Paste the messy backlog / idea dump / Concept Map JSON <b>required</b></span>
|
||||
<textarea name="idea" rows="8" required placeholder="Example: I’m 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>
|
||||
|
||||
@@ -506,7 +506,34 @@ try {
|
||||
assert.equal(mergedContext.ranked.find(item => item.id === 'workspace-dashboard').lane.source, 'source-non-goal');
|
||||
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 {
|
||||
server.kill('SIGTERM');
|
||||
}
|
||||
|
||||
@@ -468,6 +468,55 @@ function objectFrom(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 = {}) {
|
||||
const featureSet = objectFrom(input.featureSet);
|
||||
const artifact = objectFrom(input.artifact || featureSet.artifact);
|
||||
@@ -519,10 +568,10 @@ function cleanDecisionContext(input = {}) {
|
||||
const artifact = objectFrom(input.artifact || featureSet.artifact);
|
||||
const conceptMap = objectFrom(input.conceptMap || featureSet.conceptMap || artifact.conceptMap);
|
||||
const conceptMapLenses = objectFrom(conceptMap.lenses || input.lenses || featureSet.lenses);
|
||||
const riskLens = objectFrom(conceptMapLenses.risk || conceptMapLenses.risks || conceptMapLenses.boundaries || conceptMapLenses.notYet);
|
||||
const audienceLens = objectFrom(conceptMapLenses.audience || conceptMapLenses.who || conceptMapLenses.customer || conceptMapLenses.users);
|
||||
const constraintsLens = objectFrom(conceptMapLenses.constraints || conceptMapLenses.boundaries || conceptMapLenses.scope);
|
||||
const assumptionsLens = objectFrom(conceptMapLenses.assumptions || conceptMapLenses.unknowns || conceptMapLenses.openQuestions);
|
||||
const riskLens = conceptMapLenses.risk || conceptMapLenses.risks || conceptMapLenses.boundaries || conceptMapLenses.notYet;
|
||||
const audienceLens = conceptMapLenses.audience || conceptMapLenses.who || conceptMapLenses.customer || conceptMapLenses.users;
|
||||
const constraintsLens = conceptMapLenses.constraints || conceptMapLenses.boundaries || conceptMapLenses.scope;
|
||||
const assumptionsLens = conceptMapLenses.assumptions || conceptMapLenses.unknowns || conceptMapLenses.openQuestions;
|
||||
const structuredContext = objectFrom(input.context);
|
||||
const contextSources = [
|
||||
input.decisionContext,
|
||||
@@ -1050,13 +1099,14 @@ function createHandoffContract({ ranked, provenance, decisionContext }) {
|
||||
}
|
||||
|
||||
app.post('/api/rank-feedback', (req, res) => {
|
||||
const idea = cleanMultiline(req.body?.idea || '', 3000);
|
||||
const context = cleanContextText(req.body?.context || '');
|
||||
const modeId = cleanText(req.body?.mode || 'progress', 40);
|
||||
const body = expandEmbeddedRankPayload(req.body || {});
|
||||
const idea = cleanMultiline(body?.idea || '', 3000);
|
||||
const context = cleanContextText(body?.context || '');
|
||||
const modeId = cleanText(body?.mode || 'progress', 40);
|
||||
const mode = judgementModes[modeId] || judgementModes.progress;
|
||||
const provenance = cleanProvenance(req.body || {});
|
||||
const decisionContext = cleanDecisionContext(req.body || {});
|
||||
let options = optionsFromBody(req.body || {});
|
||||
const provenance = cleanProvenance(body || {});
|
||||
const decisionContext = cleanDecisionContext(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.' });
|
||||
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) }))
|
||||
@@ -1081,7 +1131,7 @@ app.post('/api/rank-feedback', (req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
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,
|
||||
brief,
|
||||
rankConfidence: rankConfidenceFor(options),
|
||||
|
||||
Reference in New Issue
Block a user