Handle structured Scattermind context
This commit is contained in:
@@ -47,7 +47,7 @@ Ranker's continuation job is narrow:
|
|||||||
|
|
||||||
`POST /api/rank-feedback` accepts a `prioritix-feature-set-v1`-style payload from Scattermind and returns ranked items plus `buildOrder.doFirst / validateNext / defer / park`. It accepts candidate arrays as `features`, `actions`, `nextMoves`, or `candidates` either at the top level or under `featureSet`, and it can consume a nested Concept Map directly, so Scattermind can hand off `conceptMap.nextActions / nextMoves` without renaming them into fake software features. Sectioned Concept Maps may also include `validateNext`, `deferred`, and `parkingLot`; Ranker combines those sections into one build-order pass while preserving `sourceSection` and treating deferred/parked sections as lane hints. Empty wrapper arrays are ignored rather than allowed to shadow a real nested Concept Map action set, and non-empty normalized wrappers are merged with Concept Map validation/deferred/parking sections rather than dropping that context. That keeps partially-normalized Scattermind exports rankable without losing the source lane contract. It also returns a `brief` with source, next-48-hour actions, carried-forward assumptions/constraints/non-goals, and `whatWouldChangeRanking` checks, plus a `handoff` object (`rank-feedback-result-v1`) with source provenance, item trace rows, and contract warnings for missing artifact IDs, source sections, original prompt provenance, or evidence on active items. Keep this contract action-first; do not use it as a reason to add generic dashboard, auth, billing, or workspace layers before the bridge has proof.
|
`POST /api/rank-feedback` accepts a `prioritix-feature-set-v1`-style payload from Scattermind and returns ranked items plus `buildOrder.doFirst / validateNext / defer / park`. It accepts candidate arrays as `features`, `actions`, `nextMoves`, or `candidates` either at the top level or under `featureSet`, and it can consume a nested Concept Map directly, so Scattermind can hand off `conceptMap.nextActions / nextMoves` without renaming them into fake software features. Sectioned Concept Maps may also include `validateNext`, `deferred`, and `parkingLot`; Ranker combines those sections into one build-order pass while preserving `sourceSection` and treating deferred/parked sections as lane hints. Empty wrapper arrays are ignored rather than allowed to shadow a real nested Concept Map action set, and non-empty normalized wrappers are merged with Concept Map validation/deferred/parking sections rather than dropping that context. That keeps partially-normalized Scattermind exports rankable without losing the source lane contract. It also returns a `brief` with source, next-48-hour actions, carried-forward assumptions/constraints/non-goals, and `whatWouldChangeRanking` checks, plus a `handoff` object (`rank-feedback-result-v1`) with source provenance, item trace rows, and contract warnings for missing artifact IDs, source sections, original prompt provenance, or evidence on active items. Keep this contract action-first; do not use it as a reason to add generic dashboard, auth, billing, or workspace layers before the bridge has proof.
|
||||||
|
|
||||||
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`, or inside `conceptMap.context`; 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.
|
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 turns that structured context into readable scoring text instead of leaking `[object Object]`. 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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -337,7 +337,40 @@ try {
|
|||||||
assert.ok(duplicateIds.handoff.warnings.some(item => /duplicate source id preview normalized to preview-2/.test(item)));
|
assert.ok(duplicateIds.handoff.warnings.some(item => /duplicate source id preview normalized to preview-2/.test(item)));
|
||||||
assert.ok(Object.values(duplicateIds.buildOrder).flat().includes('preview-2'));
|
assert.ok(Object.values(duplicateIds.buildOrder).flat().includes('preview-2'));
|
||||||
|
|
||||||
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, duplicateIds: duplicateIds.ranked.map(item => item.id), provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2));
|
const structuredContextResponse = await fetch(`${base}/api/rank-feedback`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourceName: 'Scattermind',
|
||||||
|
artifactId: 'concept_map_structured_context',
|
||||||
|
originalPrompt: 'Scattermind sent a structured context object instead of a flat context string.',
|
||||||
|
idea: 'Ranker should preserve structured context and not turn it into [object Object].',
|
||||||
|
mode: 'mvp',
|
||||||
|
context: {
|
||||||
|
summary: 'Bridge proof for a tired non-AI-native user.',
|
||||||
|
targetAudience: 'Tired non-AI-native solo builder',
|
||||||
|
constraints: ['No account before first value', 'Use a copyable artifact first'],
|
||||||
|
nonGoals: ['Avoid auth dashboard', 'Avoid saved workspaces'],
|
||||||
|
assumptions: ['Manual proof is acceptable for the first pass'],
|
||||||
|
},
|
||||||
|
conceptMap: {
|
||||||
|
nextActions: [
|
||||||
|
{ id: 'copyable-bridge-artifact', action: 'Copyable bridge artifact', why: 'Turn the source Concept Map into one defended build-order brief.', evidence: 'Can the user paste it and know the first move?', suggestedLane: 'do-first', rankerHints: { value: 9, effort: 2, confidence: 8, urgency: 8, risk: 2 } },
|
||||||
|
{ id: 'auth-dashboard', action: 'Auth dashboard', why: 'Accounts and saved workspaces for every idea.', evidence: 'None yet', suggestedLane: 'do-first', rankerHints: { value: 10, effort: 2, confidence: 9, urgency: 9, risk: 2 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(structuredContextResponse.status, 200);
|
||||||
|
const structuredContext = await structuredContextResponse.json();
|
||||||
|
assert.doesNotMatch(structuredContext.input.context, /\[object Object\]/);
|
||||||
|
assert.match(structuredContext.input.context, /Target audience: Tired non-AI-native solo builder/);
|
||||||
|
assert.deepEqual(structuredContext.input.decisionContext.constraints, ['No account before first value', 'Use a copyable artifact first']);
|
||||||
|
assert.deepEqual(structuredContext.handoff.decisionContext.nonGoals, ['Avoid auth dashboard', 'Avoid saved workspaces']);
|
||||||
|
assert.equal(structuredContext.ranked[0].id, 'copyable-bridge-artifact');
|
||||||
|
assert.equal(structuredContext.ranked.find(item => item.id === 'auth-dashboard').lane.source, 'source-non-goal');
|
||||||
|
|
||||||
|
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, duplicateIds: duplicateIds.ranked.map(item => item.id), provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2));
|
||||||
} finally {
|
} finally {
|
||||||
server.kill('SIGTERM');
|
server.kill('SIGTERM');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -422,11 +422,20 @@ function cleanProvenance(input = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function firstObject(...values) {
|
||||||
|
for (const value of values) {
|
||||||
|
const obj = objectFrom(value);
|
||||||
|
if (Object.keys(obj).length) return obj;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
function cleanDecisionContext(input = {}) {
|
function cleanDecisionContext(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);
|
||||||
const conceptMap = objectFrom(input.conceptMap || featureSet.conceptMap || artifact.conceptMap);
|
const conceptMap = objectFrom(input.conceptMap || featureSet.conceptMap || artifact.conceptMap);
|
||||||
const sourceContext = objectFrom(input.decisionContext || featureSet.decisionContext || artifact.decisionContext || conceptMap.decisionContext || conceptMap.context);
|
const structuredContext = objectFrom(input.context);
|
||||||
|
const sourceContext = firstObject(input.decisionContext, featureSet.decisionContext, artifact.decisionContext, conceptMap.decisionContext, structuredContext, conceptMap.context);
|
||||||
return {
|
return {
|
||||||
targetAudience: cleanText(input.targetAudience || featureSet.targetAudience || sourceContext.targetAudience || conceptMap.targetAudience || '', 180),
|
targetAudience: cleanText(input.targetAudience || featureSet.targetAudience || sourceContext.targetAudience || conceptMap.targetAudience || '', 180),
|
||||||
constraints: cleanFlexibleTextList(input.constraints || featureSet.constraints || sourceContext.constraints || conceptMap.constraints, 8, 180),
|
constraints: cleanFlexibleTextList(input.constraints || featureSet.constraints || sourceContext.constraints || conceptMap.constraints, 8, 180),
|
||||||
@@ -435,6 +444,18 @@ function cleanDecisionContext(input = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanContextText(value = '') {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return cleanMultiline(value || '', 3000);
|
||||||
|
const pieces = [
|
||||||
|
value.summary || value.description || value.notes || value.brief || '',
|
||||||
|
value.targetAudience && `Target audience: ${value.targetAudience}`,
|
||||||
|
...cleanFlexibleTextList(value.constraints, 8, 180).map(item => `Constraint: ${item}`),
|
||||||
|
...cleanFlexibleTextList(value.nonGoals || value.avoid, 8, 180).map(item => `Non-goal: ${item}`),
|
||||||
|
...cleanFlexibleTextList(value.assumptions, 6, 180).map(item => `Assumption: ${item}`),
|
||||||
|
].filter(Boolean);
|
||||||
|
return cleanMultiline(pieces.join('\n'), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
function meaningfulTokens(text = '') {
|
function meaningfulTokens(text = '') {
|
||||||
const stop = new Set(['the', 'and', 'with', 'from', 'that', 'this', 'into', 'before', 'after', 'user', 'users', 'idea', 'ideas', 'build', 'first', 'avoid', 'without', 'more', 'full', 'proof', 'value', 'layer']);
|
const stop = new Set(['the', 'and', 'with', 'from', 'that', 'this', 'into', 'before', 'after', 'user', 'users', 'idea', 'ideas', 'build', 'first', 'avoid', 'without', 'more', 'full', 'proof', 'value', 'layer']);
|
||||||
return [...new Set(String(text).toLowerCase().match(/[a-z0-9][a-z0-9-]{3,}/g) || [])].filter(token => !stop.has(token)).slice(0, 8);
|
return [...new Set(String(text).toLowerCase().match(/[a-z0-9][a-z0-9-]{3,}/g) || [])].filter(token => !stop.has(token)).slice(0, 8);
|
||||||
@@ -735,7 +756,7 @@ 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 idea = cleanMultiline(req.body?.idea || '', 3000);
|
||||||
const context = cleanMultiline(req.body?.context || '', 3000);
|
const context = cleanContextText(req.body?.context || '');
|
||||||
const modeId = cleanText(req.body?.mode || 'progress', 40);
|
const modeId = cleanText(req.body?.mode || 'progress', 40);
|
||||||
const mode = judgementModes[modeId] || judgementModes.progress;
|
const mode = judgementModes[modeId] || judgementModes.progress;
|
||||||
const provenance = cleanProvenance(req.body || {});
|
const provenance = cleanProvenance(req.body || {});
|
||||||
|
|||||||
Reference in New Issue
Block a user