Merge Scattermind decision context sources

This commit is contained in:
OpenClaw Bot
2026-05-27 00:03:49 +02:00
parent 25c7c08543
commit b556c67a4c
3 changed files with 95 additions and 14 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, lane-level `buildOrderDetails` with each item's reason, next step, evidence question, source section, lane source, score, and confidence, 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. If Scattermind sends the current paid Concept Map shape as lenses rather than arrays, Ranker can parse `conceptMap.lenses.channel.content` / `buildOrder` labels (`Build first`, `Test manually`, `Defer`, `Probably noise`) into rank-ready candidates and can read guardrails from the risk lens. 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`, 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]`. 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.
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.
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.
+41 -1
View File
@@ -417,7 +417,47 @@ try {
assert.ok(lensOnly.ranked.find(item => item.id === 'build-order-4').metrics.nonGoalConflicts.length >= 1);
assert.deepEqual(lensOnly.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, duplicateIds: duplicateIds.ranked.map(item => item.id), provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2));
const mergedContextResponse = await fetch(`${base}/api/rank-feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceName: 'Scattermind',
artifactId: 'concept_map_merged_context',
originalPrompt: 'Scattermind sent summary context plus deeper Concept Map guardrails and lenses.',
idea: 'Ranker should merge context sources instead of letting a shallow top-level summary shadow Concept Map constraints.',
mode: 'mvp',
context: { summary: 'Continuation bridge for an overwhelmed non-AI-native operator.' },
conceptMap: {
context: {
targetAudience: 'Overwhelmed non-AI-native shop owner',
constraints: ['Manual proof before saved projects'],
nonGoals: ['Avoid workspace dashboard'],
assumptions: ['A copyable brief is enough for the first pass'],
},
lenses: {
constraints: { content: 'No auth before first value. Keep the result copyable.' },
assumptions: { content: 'The user is tired; one defended next move beats a backlog.' },
},
nextActions: [
{ id: 'defended-next-move', action: 'Defended next-move brief', why: 'One copyable build order with source provenance.', evidence: 'Can the shop owner name the next step?', suggestedLane: 'do-first', rankerHints: { value: 9, effort: 2, confidence: 8, urgency: 8, risk: 2 } },
{ id: 'workspace-dashboard', action: 'Workspace dashboard', why: 'Accounts, saved projects, and auth dashboard.', evidence: 'No proof yet', suggestedLane: 'do-first', rankerHints: { value: 10, effort: 2, confidence: 9, urgency: 9, risk: 2 } },
],
},
}),
});
assert.equal(mergedContextResponse.status, 200);
const mergedContext = await mergedContextResponse.json();
assert.equal(mergedContext.input.decisionContext.targetAudience, 'Overwhelmed non-AI-native shop owner');
assert.ok(mergedContext.input.decisionContext.constraints.includes('Manual proof before saved projects'));
assert.ok(mergedContext.input.decisionContext.constraints.includes('No auth before first value'));
assert.ok(mergedContext.input.decisionContext.nonGoals.includes('Avoid workspace dashboard'));
assert.ok(mergedContext.input.decisionContext.assumptions.includes('A copyable brief is enough for the first pass'));
assert.ok(mergedContext.input.decisionContext.assumptions.includes('The user is tired'));
assert.equal(mergedContext.ranked[0].id, 'defended-next-move');
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, mergedContextTop: mergedContext.ranked[0].id, duplicateIds: duplicateIds.ranked.map(item => item.id), provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2));
} finally {
server.kill('SIGTERM');
}
+53 -12
View File
@@ -462,12 +462,33 @@ function cleanProvenance(input = {}) {
};
}
function firstObject(...values) {
for (const value of values) {
const obj = objectFrom(value);
if (Object.keys(obj).length) return obj;
function lensContent(lens = {}) {
if (Array.isArray(lens)) return lens;
const obj = objectFrom(lens);
return obj.content || obj.text || obj.summary || obj.items || '';
}
function cleanSentenceList(value = '', maxItems = 8, maxText = 180) {
if (Array.isArray(value)) return cleanFlexibleTextList(value, maxItems, maxText);
return contextSentences(value).map(item => cleanText(item, maxText)).filter(Boolean).slice(0, maxItems);
}
function collectContextList(sources = [], aliases = [], maxItems = 8) {
return uniqueList(sources.flatMap(source => {
const obj = objectFrom(source);
return aliases.flatMap(alias => cleanFlexibleTextList(obj[alias], maxItems, 180));
}), maxItems);
}
function firstContextText(sources = [], aliases = []) {
for (const source of sources) {
const obj = objectFrom(source);
for (const alias of aliases) {
const cleaned = cleanText(obj[alias], 180);
if (cleaned) return cleaned;
}
return {};
}
return '';
}
function cleanDecisionContext(input = {}) {
@@ -475,25 +496,45 @@ 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);
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 structuredContext = objectFrom(input.context);
const sourceContext = firstObject(input.decisionContext, featureSet.decisionContext, artifact.decisionContext, conceptMap.decisionContext, structuredContext, conceptMap.context);
const contextSources = [
input.decisionContext,
featureSet.decisionContext,
artifact.decisionContext,
conceptMap.decisionContext,
structuredContext,
conceptMap.context,
featureSet.context,
artifact.context,
];
const textContextGuardrails = guardrailsFromContextText([
typeof input.context === 'string' ? input.context : '',
riskLens.content || riskLens.text || '',
lensContent(riskLens),
lensContent(constraintsLens),
conceptMap.risk || conceptMap.whatNotToBuildYet || conceptMap.notYet || '',
].filter(Boolean).join('\n'));
return {
targetAudience: cleanText(input.targetAudience || featureSet.targetAudience || sourceContext.targetAudience || conceptMap.targetAudience || '', 180),
targetAudience: cleanText(input.targetAudience || featureSet.targetAudience || firstContextText(contextSources, ['targetAudience', 'audience', 'who', 'whoItHelps', 'customer', 'users']) || conceptMap.targetAudience || lensContent(audienceLens), 180),
constraints: uniqueList([
...cleanFlexibleTextList(input.constraints || featureSet.constraints || sourceContext.constraints || conceptMap.constraints, 8, 180),
...cleanFlexibleTextList(input.constraints || featureSet.constraints || conceptMap.constraints, 8, 180),
...collectContextList(contextSources, ['constraints', 'constraint', 'boundaries', 'scope'], 8),
...cleanSentenceList(lensContent(constraintsLens), 8, 180),
...textContextGuardrails.constraints,
], 8),
nonGoals: uniqueList([
...cleanFlexibleTextList(input.nonGoals || input.avoid || featureSet.nonGoals || featureSet.avoid || sourceContext.nonGoals || sourceContext.avoid || conceptMap.nonGoals || conceptMap.avoid, 8, 180),
...cleanFlexibleTextList(input.nonGoals || input.avoid || featureSet.nonGoals || featureSet.avoid || conceptMap.nonGoals || conceptMap.avoid, 8, 180),
...collectContextList(contextSources, ['nonGoals', 'nonGoal', 'avoid', 'notYet', 'doNotBuild'], 8),
...textContextGuardrails.nonGoals,
], 8),
assumptions: cleanFlexibleTextList(input.assumptions || featureSet.assumptions || sourceContext.assumptions || conceptMap.assumptions, 6, 180),
assumptions: uniqueList([
...cleanFlexibleTextList(input.assumptions || featureSet.assumptions || conceptMap.assumptions, 6, 180),
...collectContextList(contextSources, ['assumptions', 'assumption', 'unknowns', 'openQuestions'], 6),
...cleanSentenceList(lensContent(assumptionsLens), 6, 180),
], 6),
};
}