Handle structured Scattermind context

This commit is contained in:
OpenClaw Bot
2026-05-26 23:40:46 +02:00
parent 65832f9b56
commit adcef9a6f7
3 changed files with 58 additions and 4 deletions
+1 -1
View File
@@ -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 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`, 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 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 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.
+34 -1
View File
@@ -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');
} }
+23 -2
View File
@@ -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 || {});