Harden rank feedback duplicate ids
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 `conceptMap.nextActions / nextMoves` artifact directly, so Scattermind can hand off Concept Map next actions without renaming them into fake software features. 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 `conceptMap.nextActions / nextMoves` artifact directly, so Scattermind can hand off Concept Map next actions without renaming them into fake software features. 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).
|
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.
|
||||||
|
|
||||||
Recommended payload shape:
|
Recommended payload shape:
|
||||||
|
|
||||||
|
|||||||
@@ -208,7 +208,34 @@ try {
|
|||||||
assert.deepEqual(nonGoal.handoff.itemTrace.find(item => item.id === 'workspace-autopilot').nonGoalConflicts, workspace.metrics.nonGoalConflicts);
|
assert.deepEqual(nonGoal.handoff.itemTrace.find(item => item.id === 'workspace-autopilot').nonGoalConflicts, workspace.metrics.nonGoalConflicts);
|
||||||
assert.ok(!nonGoal.buildOrder.doFirst.includes('workspace-autopilot'));
|
assert.ok(!nonGoal.buildOrder.doFirst.includes('workspace-autopilot'));
|
||||||
|
|
||||||
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, provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2));
|
const duplicateIdResponse = await fetch(`${base}/api/rank-feedback`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourceName: 'Scattermind',
|
||||||
|
artifactId: 'concept_map_duplicate_ids',
|
||||||
|
originalPrompt: 'I pasted duplicated next-move IDs from a messy Concept Map export.',
|
||||||
|
idea: 'Ranker should keep handoff rows addressable even if Scattermind sends duplicate candidate IDs.',
|
||||||
|
mode: 'mvp',
|
||||||
|
conceptMap: {
|
||||||
|
nextActions: [
|
||||||
|
{ id: 'preview', action: 'Manual build-order preview', why: 'Defends the first move without dashboard machinery.', evidence: 'Can one user act on the preview?', suggestedLane: 'do-first' },
|
||||||
|
{ id: 'preview', action: 'Copyable preview brief', why: 'Exports the defended order into notes.', evidence: 'Does the copied brief preserve the next action?', suggestedLane: 'validate-next' },
|
||||||
|
{ id: 'workspace', action: 'Saved workspace', why: 'Auth dashboard for every idea.', evidence: 'None yet', suggestedLane: 'park' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(duplicateIdResponse.status, 200);
|
||||||
|
const duplicateIds = await duplicateIdResponse.json();
|
||||||
|
assert.deepEqual(duplicateIds.ranked.map(item => item.id).sort(), ['preview', 'preview-2', 'workspace']);
|
||||||
|
assert.equal(new Set(duplicateIds.handoff.itemTrace.map(item => item.id)).size, duplicateIds.handoff.itemTrace.length);
|
||||||
|
assert.equal(duplicateIds.handoff.itemTrace.find(item => item.id === 'preview-2').originalId, 'preview');
|
||||||
|
assert.equal(duplicateIds.handoff.itemTrace.find(item => item.id === 'preview-2').idNormalized, true);
|
||||||
|
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'));
|
||||||
|
|
||||||
|
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));
|
||||||
} finally {
|
} finally {
|
||||||
server.kill('SIGTERM');
|
server.kill('SIGTERM');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -477,6 +477,25 @@ function normalizeFeatureOption(item, index, fallbackId = 'feature', defaultSour
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOptionIds(options = []) {
|
||||||
|
const seen = new Map();
|
||||||
|
return options.map((option, index) => {
|
||||||
|
const baseId = cleanText(option.id || `option-${index + 1}`, 80) || `option-${index + 1}`;
|
||||||
|
const count = seen.get(baseId) || 0;
|
||||||
|
seen.set(baseId, count + 1);
|
||||||
|
if (count === 0) return { ...option, id: baseId };
|
||||||
|
return {
|
||||||
|
...option,
|
||||||
|
id: cleanText(`${baseId}-${count + 1}`, 80),
|
||||||
|
provenance: {
|
||||||
|
...(option.provenance || {}),
|
||||||
|
originalId: baseId,
|
||||||
|
idNormalized: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function candidateArrayFrom(...entries) {
|
function candidateArrayFrom(...entries) {
|
||||||
return entries.find(entry => Array.isArray(entry?.items)) || null;
|
return entries.find(entry => Array.isArray(entry?.items)) || null;
|
||||||
}
|
}
|
||||||
@@ -500,12 +519,12 @@ function optionsFromBody(body = {}) {
|
|||||||
);
|
);
|
||||||
if (rawCandidates) {
|
if (rawCandidates) {
|
||||||
const fallbackId = rawCandidates.sourceSection.toLowerCase().includes('action') ? 'action' : 'feature';
|
const fallbackId = rawCandidates.sourceSection.toLowerCase().includes('action') ? 'action' : 'feature';
|
||||||
return rawCandidates.items.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, fallbackId, rawCandidates.sourceSection)).filter(item => item.title);
|
return normalizeOptionIds(rawCandidates.items.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, fallbackId, rawCandidates.sourceSection)).filter(item => item.title));
|
||||||
}
|
}
|
||||||
if (Array.isArray(body.options)) {
|
if (Array.isArray(body.options)) {
|
||||||
return body.options.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, 'option', 'options')).filter(item => item.title);
|
return normalizeOptionIds(body.options.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, 'option', 'options')).filter(item => item.title));
|
||||||
}
|
}
|
||||||
return parseOptionsFromText(body.optionsText || featureSet.optionsText || conceptMap.optionsText || '');
|
return normalizeOptionIds(parseOptionsFromText(body.optionsText || featureSet.optionsText || conceptMap.optionsText || ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoreOption(option, mode, context = '', decisionContext = {}) {
|
function scoreOption(option, mode, context = '', decisionContext = {}) {
|
||||||
@@ -664,6 +683,7 @@ function createHandoffContract({ ranked, provenance, decisionContext }) {
|
|||||||
|
|
||||||
const itemTrace = ranked.map(item => {
|
const itemTrace = ranked.map(item => {
|
||||||
if (!item.provenance?.sourceSection) warnings.push(`missing source section for ${item.id}`);
|
if (!item.provenance?.sourceSection) warnings.push(`missing source section for ${item.id}`);
|
||||||
|
if (item.provenance?.idNormalized) warnings.push(`duplicate source id ${item.provenance.originalId} normalized to ${item.id}`);
|
||||||
if (!item.factors?.evidenceNeeded && ['do', 'test'].includes(item.lane?.id)) warnings.push(`missing evidence needed for active item ${item.id}`);
|
if (!item.factors?.evidenceNeeded && ['do', 'test'].includes(item.lane?.id)) warnings.push(`missing evidence needed for active item ${item.id}`);
|
||||||
if (item.metrics?.nonGoalConflicts?.length && ['do', 'test'].includes(item.lane?.id)) warnings.push(`active item ${item.id} conflicts with source non-goals: ${item.metrics.nonGoalConflicts.join('; ')}`);
|
if (item.metrics?.nonGoalConflicts?.length && ['do', 'test'].includes(item.lane?.id)) warnings.push(`active item ${item.id} conflicts with source non-goals: ${item.metrics.nonGoalConflicts.join('; ')}`);
|
||||||
return {
|
return {
|
||||||
@@ -672,6 +692,8 @@ function createHandoffContract({ ranked, provenance, decisionContext }) {
|
|||||||
lane: item.lane?.id || 'defer',
|
lane: item.lane?.id || 'defer',
|
||||||
sourceSection: item.provenance?.sourceSection || '',
|
sourceSection: item.provenance?.sourceSection || '',
|
||||||
sourceId: item.provenance?.sourceId || '',
|
sourceId: item.provenance?.sourceId || '',
|
||||||
|
originalId: item.provenance?.originalId || '',
|
||||||
|
idNormalized: Boolean(item.provenance?.idNormalized),
|
||||||
evidenceNeeded: item.factors?.evidenceNeeded || '',
|
evidenceNeeded: item.factors?.evidenceNeeded || '',
|
||||||
nonGoalConflicts: item.metrics?.nonGoalConflicts || [],
|
nonGoalConflicts: item.metrics?.nonGoalConflicts || [],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user