1315 lines
69 KiB
JavaScript
1315 lines
69 KiB
JavaScript
import 'dotenv/config';
|
||
import express from 'express';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import crypto from 'node:crypto';
|
||
import { Client, TablesDB, ID, Query } from 'node-appwrite';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
const PORT = Number(process.env.PORT || 3045);
|
||
const endpoint = process.env.APPWRITE_ENDPOINT || process.env.APPWRITE_LOCAL_ENDPOINT || process.env.APPWRITE_SELF_HOSTED_URL;
|
||
const projectId = process.env.APPWRITE_PROJECT_ID || process.env.APPWRITE_LOCAL_PROJECT_ID || process.env.APPWRITE_SELF_HOSTED_PROJECT_ID;
|
||
const apiKey = process.env.APPWRITE_API_KEY || process.env.APPWRITE_LOCAL_API_KEY || process.env.APPWRITE_SELF_HOSTED_API_KEY;
|
||
const databaseId = process.env.RANK_APPWRITE_DATABASE_ID || process.env.APPWRITE_DATABASE_ID || 'priority_rank';
|
||
const ideasTableId = process.env.RANK_IDEAS_TABLE_ID || 'ideas';
|
||
const milestonesTableId = process.env.RANK_MILESTONES_TABLE_ID || 'milestones';
|
||
const activityTableId = process.env.RANK_ACTIVITY_TABLE_ID || 'activity';
|
||
const agentToken = process.env.RANK_AGENT_TOKEN || process.env.PRIORITY_AGENT_TOKEN || '';
|
||
const appVersion = process.env.APP_VERSION || 'rank-local';
|
||
const appwriteTimeoutMs = Number(process.env.APPWRITE_TIMEOUT_MS || 7000);
|
||
|
||
function withTimeout(promise, label = 'operation') {
|
||
let timer;
|
||
const timeout = new Promise((_, reject) => {
|
||
timer = setTimeout(() => reject(new Error(`${label} timed out after ${appwriteTimeoutMs}ms`)), appwriteTimeoutMs);
|
||
});
|
||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||
}
|
||
|
||
if (!endpoint || !projectId || !apiKey) {
|
||
console.warn('[rank] Missing Appwrite configuration; /api/health will report degraded.');
|
||
}
|
||
|
||
const client = new Client();
|
||
if (endpoint) client.setEndpoint(endpoint);
|
||
if (projectId) client.setProject(projectId);
|
||
if (apiKey) client.setKey(apiKey);
|
||
const tables = new TablesDB(client);
|
||
const app = express();
|
||
|
||
app.use(express.json({ limit: '256kb' }));
|
||
app.use(express.static(path.join(__dirname, 'public'), {
|
||
etag: true,
|
||
maxAge: process.env.NODE_ENV === 'production' ? '10m' : 0,
|
||
}));
|
||
|
||
function clampInt(value, fallback, min = 0, max = 10) {
|
||
const n = Number.parseInt(value, 10);
|
||
if (!Number.isFinite(n)) return fallback;
|
||
return Math.min(max, Math.max(min, n));
|
||
}
|
||
|
||
function cleanText(value, max = 1000) {
|
||
return String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
|
||
}
|
||
|
||
function cleanMultiline(value, max = 6000) {
|
||
return String(value ?? '').replace(/\r\n/g, '\n').trim().slice(0, max);
|
||
}
|
||
|
||
function scoreIdea({ impact, effort, confidence, urgency }) {
|
||
const i = clampInt(impact, 5);
|
||
const e = clampInt(effort, 5, 1, 10);
|
||
const c = clampInt(confidence, 5);
|
||
const u = clampInt(urgency, 5);
|
||
return Number((((i * 2.4) + (c * 1.2) + (u * 1.4)) / Math.max(1, e)).toFixed(2));
|
||
}
|
||
|
||
function ideaDataFromInput(input = {}, defaults = {}) {
|
||
const title = cleanText(input.title || input.name, 180);
|
||
if (!title) throw Object.assign(new Error('Every feature needs a title.'), { code: 400 });
|
||
const data = {
|
||
title,
|
||
description: cleanMultiline(input.description || input.brief || '', 5000),
|
||
source: cleanText(input.source || defaults.source || 'import', 40),
|
||
sourceName: cleanText(input.sourceName || input.owner || defaults.sourceName || 'Prioritix feature set', 80),
|
||
status: cleanText(input.status || defaults.status || 'inbox', 40),
|
||
milestoneId: cleanText(input.milestoneId || input.milestone || defaults.milestoneId || 'inbox', 64),
|
||
impact: clampInt(input.impact, defaults.impact ?? 5),
|
||
effort: clampInt(input.effort, defaults.effort ?? 5, 1, 10),
|
||
confidence: clampInt(input.confidence, defaults.confidence ?? 6),
|
||
urgency: clampInt(input.urgency, defaults.urgency ?? 5),
|
||
rank: clampInt(input.rank, defaults.rank ?? 0, -100000, 100000),
|
||
labels: encodeList(input.labels || input.category || input.categories || []),
|
||
notes: cleanMultiline(input.notes || '', 4000),
|
||
archived: Boolean(input.archived ?? false),
|
||
};
|
||
data.score = scoreIdea(data);
|
||
return data;
|
||
}
|
||
|
||
function publicIdea(row) {
|
||
return {
|
||
id: row.$id,
|
||
createdAt: row.$createdAt,
|
||
updatedAt: row.$updatedAt,
|
||
title: row.title,
|
||
description: row.description || '',
|
||
source: row.source || 'human',
|
||
sourceName: row.sourceName || '',
|
||
status: row.status || 'inbox',
|
||
milestoneId: row.milestoneId || 'inbox',
|
||
impact: row.impact ?? 5,
|
||
effort: row.effort ?? 5,
|
||
confidence: row.confidence ?? 5,
|
||
urgency: row.urgency ?? 5,
|
||
score: row.score ?? 0,
|
||
rank: row.rank ?? 0,
|
||
labels: parseList(row.labels),
|
||
notes: row.notes || '',
|
||
archived: Boolean(row.archived),
|
||
};
|
||
}
|
||
|
||
function publicMilestone(row) {
|
||
return {
|
||
id: row.$id,
|
||
createdAt: row.$createdAt,
|
||
updatedAt: row.$updatedAt,
|
||
name: row.name,
|
||
description: row.description || '',
|
||
horizon: row.horizon || '',
|
||
color: row.color || '#8cf7ff',
|
||
position: row.position ?? 0,
|
||
active: row.active !== false,
|
||
};
|
||
}
|
||
|
||
function parseList(value) {
|
||
if (!value) return [];
|
||
try {
|
||
const parsed = JSON.parse(value);
|
||
return Array.isArray(parsed) ? parsed.map(String).slice(0, 12) : [];
|
||
} catch {
|
||
return String(value).split(',').map(s => s.trim()).filter(Boolean).slice(0, 12);
|
||
}
|
||
}
|
||
|
||
function encodeList(value) {
|
||
if (Array.isArray(value)) return JSON.stringify(value.map(v => cleanText(v, 32)).filter(Boolean).slice(0, 12));
|
||
return JSON.stringify(parseList(value));
|
||
}
|
||
|
||
function cleanTextList(value, maxItems = 6, maxText = 180) {
|
||
const list = Array.isArray(value) ? value : parseList(value);
|
||
return list.map(item => cleanText(item, maxText)).filter(Boolean).slice(0, maxItems);
|
||
}
|
||
|
||
function cleanFlexibleTextList(value, maxItems = 8, maxText = 180) {
|
||
if (Array.isArray(value)) return value.map(item => cleanText(item, maxText)).filter(Boolean).slice(0, maxItems);
|
||
const text = cleanMultiline(value || '', maxItems * maxText);
|
||
if (!text) return [];
|
||
return text
|
||
.split(/\n|;|\|/)
|
||
.map(item => item.replace(/^\s*[-*•\d.)]+\s*/, '').trim())
|
||
.filter(Boolean)
|
||
.map(item => cleanText(item, maxText))
|
||
.slice(0, maxItems);
|
||
}
|
||
|
||
function uniqueList(items = [], maxItems = 8) {
|
||
const seen = new Set();
|
||
return items.filter(item => {
|
||
const cleaned = cleanText(item, 180);
|
||
const key = cleaned.toLowerCase();
|
||
if (!cleaned || seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
}).slice(0, maxItems);
|
||
}
|
||
|
||
function contextSentences(value = '') {
|
||
return cleanMultiline(value, 3000)
|
||
.split(/\n|;|\.|\|/)
|
||
.map(item => item.replace(/^\s*[-*•\d.)]+\s*/, '').trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function guardrailsFromContextText(value = '') {
|
||
const nonGoals = [];
|
||
const constraints = [];
|
||
for (const sentence of contextSentences(value)) {
|
||
const cleaned = cleanText(sentence, 180);
|
||
if (!cleaned) continue;
|
||
if (/^(avoid|no|do not|don't|dont|must not|never)\b/i.test(cleaned)) nonGoals.push(cleaned);
|
||
else if (/\b(avoid|no auth|no account|no billing|no workspace|not a dashboard|without accounts|before proof|manual proof|solo builder|constraint)\b/i.test(cleaned)) constraints.push(cleaned);
|
||
}
|
||
return { nonGoals: uniqueList(nonGoals), constraints: uniqueList(constraints) };
|
||
}
|
||
|
||
function cleanMetricHints(item = {}) {
|
||
const raw = {
|
||
...(item.factors && typeof item.factors === 'object' ? item.factors : {}),
|
||
...(item.scoring && typeof item.scoring === 'object' ? item.scoring : {}),
|
||
...(item.rankerHints && typeof item.rankerHints === 'object' ? item.rankerHints : {}),
|
||
};
|
||
const aliases = {
|
||
value: ['value', 'impact', 'userImpact', 'userValueScore'],
|
||
effort: ['effort', 'buildEffort', 'complexity'],
|
||
confidence: ['confidence', 'certainty'],
|
||
urgency: ['urgency', 'timing'],
|
||
revenue: ['revenue', 'commercial', 'buyerSignal'],
|
||
novelty: ['novelty', 'differentiation', 'originality'],
|
||
risk: ['risk', 'assumptionRisk', 'scopeRisk'],
|
||
};
|
||
return Object.fromEntries(Object.entries(aliases).map(([metric, keys]) => {
|
||
const found = keys.map(key => raw[key] ?? item[key]).find(value => value !== undefined && value !== null && value !== '');
|
||
const parsed = Number.parseFloat(found);
|
||
return [metric, Number.isFinite(parsed) ? Math.min(10, Math.max(1, parsed)) : null];
|
||
}).filter(([, value]) => value !== null));
|
||
}
|
||
|
||
function blendMetric(heuristic, explicit, weight = 0.58) {
|
||
if (!Number.isFinite(explicit)) return heuristic;
|
||
return Math.max(1, Math.min(10, heuristic * (1 - weight) + explicit * weight));
|
||
}
|
||
|
||
function rowsFrom(result) {
|
||
const rows = result?.rows || result?.documents;
|
||
if (!Array.isArray(rows)) throw new Error('Appwrite returned an invalid table response');
|
||
return rows;
|
||
}
|
||
|
||
function assertRow(row) {
|
||
if (!row?.$id) throw new Error('Appwrite returned an invalid row response');
|
||
return row;
|
||
}
|
||
|
||
function requireAgent(req, res, next) {
|
||
if (!agentToken) return next();
|
||
const header = req.get('authorization') || '';
|
||
const token = header.startsWith('Bearer ') ? header.slice(7) : req.get('x-rank-token');
|
||
const tokenBuffer = Buffer.from(token || '');
|
||
const expectedBuffer = Buffer.from(agentToken);
|
||
if (tokenBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(tokenBuffer, expectedBuffer)) return next();
|
||
return res.status(401).json({ error: 'agent token required' });
|
||
}
|
||
|
||
async function logActivity(type, message, ideaId = '', meta = '') {
|
||
try {
|
||
await tables.createRow({
|
||
databaseId,
|
||
tableId: activityTableId,
|
||
rowId: ID.unique(),
|
||
data: { type, message: cleanText(message, 300), ideaId, meta: cleanText(meta, 800) },
|
||
});
|
||
} catch (error) {
|
||
console.warn('[rank] activity log failed', error.message);
|
||
}
|
||
}
|
||
|
||
app.get('/api/health', async (_req, res) => {
|
||
const health = { ok: false, app: 'rank', version: appVersion, appwriteConfigured: Boolean(endpoint && projectId && apiKey), appwriteReachable: false, tableReachable: false };
|
||
try {
|
||
if (health.appwriteConfigured) {
|
||
const probe = await withTimeout(
|
||
tables.listRows({ databaseId, tableId: ideasTableId, queries: [Query.limit(1)] }),
|
||
'Appwrite health probe'
|
||
);
|
||
rowsFrom(probe);
|
||
health.appwriteReachable = true;
|
||
health.tableReachable = true;
|
||
health.ok = true;
|
||
}
|
||
} catch (error) {
|
||
health.error = error.message;
|
||
}
|
||
res.status(health.ok ? 200 : 503).json(health);
|
||
});
|
||
|
||
app.get('/api/bootstrap', async (_req, res) => {
|
||
const [ideas, milestones, activity] = await withTimeout(Promise.all([
|
||
tables.listRows({ databaseId, tableId: ideasTableId, queries: [Query.equal('archived', false), Query.orderDesc('score'), Query.orderAsc('rank'), Query.limit(100)] }),
|
||
tables.listRows({ databaseId, tableId: milestonesTableId, queries: [Query.equal('active', true), Query.orderAsc('position'), Query.limit(50)] }),
|
||
tables.listRows({ databaseId, tableId: activityTableId, queries: [Query.orderDesc('$createdAt'), Query.limit(18)] }).catch(() => ({ rows: [], documents: [] })),
|
||
]), 'Appwrite bootstrap');
|
||
res.json({
|
||
version: appVersion,
|
||
ideas: rowsFrom(ideas).map(publicIdea),
|
||
milestones: rowsFrom(milestones).map(publicMilestone),
|
||
activity: rowsFrom(activity).map(row => ({ id: row.$id, createdAt: row.$createdAt, type: row.type, message: row.message, ideaId: row.ideaId || '' })),
|
||
scoring: '((impact×2.4)+(confidence×1.2)+(urgency×1.4))/effort',
|
||
});
|
||
});
|
||
|
||
app.post('/api/ideas', requireAgent, async (req, res) => {
|
||
const data = ideaDataFromInput(req.body, { source: 'human', status: 'inbox', milestoneId: 'inbox' });
|
||
const row = assertRow(await tables.createRow({ databaseId, tableId: ideasTableId, rowId: ID.unique(), data }));
|
||
await logActivity('idea.created', `Captured “${data.title}”`, row.$id, data.source);
|
||
res.status(201).json(publicIdea(row));
|
||
});
|
||
|
||
app.post('/api/feature-set/import', requireAgent, async (req, res) => {
|
||
const features = Array.isArray(req.body.features) ? req.body.features.slice(0, 100) : [];
|
||
if (!features.length) return res.status(400).json({ error: 'Feature set must include a non-empty features array.' });
|
||
const defaults = req.body.defaults && typeof req.body.defaults === 'object' ? req.body.defaults : {};
|
||
const created = [];
|
||
const errors = [];
|
||
for (const [index, feature] of features.entries()) {
|
||
try {
|
||
const data = ideaDataFromInput(feature, { source: 'import', sourceName: req.body.name || 'Prioritix feature set', ...defaults });
|
||
const row = assertRow(await tables.createRow({ databaseId, tableId: ideasTableId, rowId: ID.unique(), data }));
|
||
created.push(publicIdea(row));
|
||
} catch (error) {
|
||
errors.push({ index, title: cleanText(feature?.title || feature?.name || '', 180), error: error.message });
|
||
}
|
||
}
|
||
if (created.length) await logActivity('feature_set.imported', `Imported ${created.length} feature${created.length === 1 ? '' : 's'}`, '', req.body.name || 'feature-set-v1');
|
||
res.status(created.length ? 201 : 400).json({ ok: errors.length === 0, imported: created.length, created, errors });
|
||
});
|
||
|
||
app.patch('/api/ideas/:id', requireAgent, async (req, res) => {
|
||
const allowed = ['title', 'description', 'source', 'sourceName', 'status', 'milestoneId', 'impact', 'effort', 'confidence', 'urgency', 'rank', 'labels', 'notes', 'archived'];
|
||
const data = {};
|
||
for (const key of allowed) {
|
||
if (!(key in req.body)) continue;
|
||
if (['impact', 'effort', 'confidence', 'urgency', 'rank'].includes(key)) data[key] = clampInt(req.body[key], key === 'effort' ? 5 : 0, key === 'effort' ? 1 : -100000, key === 'rank' ? 100000 : 10);
|
||
else if (key === 'description' || key === 'notes') data[key] = cleanMultiline(req.body[key], key === 'description' ? 5000 : 4000);
|
||
else if (key === 'labels') data[key] = encodeList(req.body[key]);
|
||
else if (key === 'archived') data[key] = Boolean(req.body[key]);
|
||
else data[key] = cleanText(req.body[key], key === 'title' ? 180 : 80);
|
||
}
|
||
if (['impact', 'effort', 'confidence', 'urgency'].some(k => k in data)) {
|
||
const current = assertRow(await tables.getRow({ databaseId, tableId: ideasTableId, rowId: req.params.id }));
|
||
data.score = scoreIdea({ ...current, ...data });
|
||
}
|
||
const row = assertRow(await tables.updateRow({ databaseId, tableId: ideasTableId, rowId: req.params.id, data }));
|
||
await logActivity('idea.updated', `Updated “${row.title}”`, row.$id, Object.keys(data).join(','));
|
||
res.json(publicIdea(row));
|
||
});
|
||
|
||
app.post('/api/milestones', requireAgent, async (req, res) => {
|
||
const name = cleanText(req.body.name, 80);
|
||
if (!name) return res.status(400).json({ error: 'name is required' });
|
||
const data = {
|
||
name,
|
||
description: cleanMultiline(req.body.description, 1000),
|
||
horizon: cleanText(req.body.horizon || '', 80),
|
||
color: cleanText(req.body.color || '#8cf7ff', 24),
|
||
position: clampInt(req.body.position, 0, -10000, 10000),
|
||
active: req.body.active !== false,
|
||
};
|
||
const row = assertRow(await tables.createRow({ databaseId, tableId: milestonesTableId, rowId: ID.unique(), data }));
|
||
await logActivity('milestone.created', `Added milestone “${name}”`, '', row.$id);
|
||
res.status(201).json(publicMilestone(row));
|
||
});
|
||
|
||
app.patch('/api/milestones/:id', requireAgent, async (req, res) => {
|
||
const data = {};
|
||
for (const key of ['name', 'description', 'horizon', 'color', 'position', 'active']) {
|
||
if (!(key in req.body)) continue;
|
||
if (key === 'position') data[key] = clampInt(req.body[key], 0, -10000, 10000);
|
||
else if (key === 'active') data[key] = Boolean(req.body[key]);
|
||
else if (key === 'description') data[key] = cleanMultiline(req.body[key], 1000);
|
||
else data[key] = cleanText(req.body[key], key === 'name' ? 80 : 120);
|
||
}
|
||
const row = assertRow(await tables.updateRow({ databaseId, tableId: milestonesTableId, rowId: req.params.id, data }));
|
||
await logActivity('milestone.updated', `Updated milestone “${row.name}”`, '', row.$id);
|
||
res.json(publicMilestone(row));
|
||
});
|
||
|
||
app.post('/api/reorder', requireAgent, async (req, res) => {
|
||
const updates = Array.isArray(req.body.updates) ? req.body.updates.slice(0, 100) : [];
|
||
const changed = [];
|
||
for (const item of updates) {
|
||
if (!item?.id) continue;
|
||
const data = {};
|
||
if ('rank' in item) data.rank = clampInt(item.rank, 0, -100000, 100000);
|
||
if ('milestoneId' in item) data.milestoneId = cleanText(item.milestoneId, 64);
|
||
if ('status' in item) data.status = cleanText(item.status, 40);
|
||
if (Object.keys(data).length) {
|
||
const row = assertRow(await tables.updateRow({ databaseId, tableId: ideasTableId, rowId: item.id, data }));
|
||
changed.push(publicIdea(row));
|
||
}
|
||
}
|
||
if (changed.length) await logActivity('ideas.reordered', `Re-ranked ${changed.length} item${changed.length === 1 ? '' : 's'}`);
|
||
res.json({ changed });
|
||
});
|
||
|
||
const judgementModes = {
|
||
progress: {
|
||
label: 'Fastest useful progress',
|
||
weights: { value: 1.2, feasibility: 1.55, confidence: 1.25, urgency: 1.15, revenue: 0.55, novelty: 0.35, risk: -1.05 },
|
||
next: 'Test the highest-ranked low-effort option manually before adding product machinery.',
|
||
},
|
||
mvp: {
|
||
label: 'Best MVP order',
|
||
weights: { value: 1.55, feasibility: 1.25, confidence: 1.15, urgency: 0.85, revenue: 0.75, novelty: 0.45, risk: -1.2 },
|
||
next: 'Build the smallest slice that proves the core user promise, then defer everything that needs the proof first.',
|
||
},
|
||
revenue: {
|
||
label: 'Revenue potential',
|
||
weights: { value: 1.15, feasibility: 0.75, confidence: 0.95, urgency: 1.05, revenue: 1.85, novelty: 0.35, risk: -1.0 },
|
||
next: 'Validate willingness to pay before polishing delivery. A paid manual version beats a beautiful unpaid feature.',
|
||
},
|
||
risk: {
|
||
label: 'Safest proof order',
|
||
weights: { value: 0.95, feasibility: 1.0, confidence: 1.45, urgency: 0.8, revenue: 0.45, novelty: 0.25, risk: -1.85 },
|
||
next: 'Start with the option that reduces risk without betting the roadmap on it.',
|
||
},
|
||
validation: {
|
||
label: 'Fastest validation',
|
||
weights: { value: 1.25, feasibility: 1.7, confidence: 1.35, urgency: 1.1, revenue: 0.35, novelty: 0.25, risk: -0.85 },
|
||
next: 'Choose the option that can produce real user evidence fastest, even if the first version is manual.',
|
||
},
|
||
learning: {
|
||
label: 'Biggest assumption test',
|
||
weights: { value: 1.1, feasibility: 1.35, confidence: -0.35, urgency: 0.55, revenue: 0.45, novelty: 0.45, risk: 1.15 },
|
||
next: 'Pick the riskiest important assumption that can be tested cheaply. The goal is learning, not shipping surface area.',
|
||
},
|
||
originality: {
|
||
label: 'Most original / differentiated',
|
||
weights: { value: 1.0, feasibility: 0.65, confidence: 0.75, urgency: 0.55, revenue: 0.65, novelty: 1.85, risk: -0.9 },
|
||
next: 'Keep the unusual angle, but demand one proof step so originality does not become expensive weirdness.',
|
||
},
|
||
};
|
||
|
||
const wordSets = {
|
||
revenue: ['pay', 'paid', 'price', 'pricing', 'stripe', 'checkout', 'invoice', 'sales', 'sell', 'buyer', 'subscription', 'revenue', 'client', 'customer', 'conversion', 'upsell'],
|
||
value: ['pain', 'problem', 'save', 'faster', 'clear', 'clarity', 'decision', 'feedback', 'user', 'customer', 'relief', 'manual', 'core', 'must', 'need', 'trust'],
|
||
effort: ['dashboard', 'workspace', 'workspaces', 'collaboration', 'realtime', 'integration', 'integrations', 'automation', 'accounts', 'auth', 'billing', 'subscription', 'pricing', 'checkout', 'invoices', 'ai', 'model', 'mobile', 'sync', 'admin', 'export', 'exportable', 'slack', 'notion', 'team', 'voting'],
|
||
risk: ['unclear', 'maybe', 'complex', 'hard', 'risky', 'unknown', 'depends', 'enterprise', 'platform', 'everything', 'all users', 'team voting', 'marketplace', 'saved workspaces', 'team voting'],
|
||
novelty: ['new', 'novel', 'different', 'unique', 'original', 'weird', 'unexpected', 'expert', 'judgement', 'reflection', 'rank', 'compare'],
|
||
urgency: ['now', 'launch', 'blocker', 'first', 'mvp', 'today', 'week', 'urgent', 'stuck', 'before', 'next'],
|
||
};
|
||
|
||
function hits(text, words) {
|
||
const lower = ` ${String(text || '').toLowerCase()} `;
|
||
return words.reduce((count, word) => count + (lower.includes(word) ? 1 : 0), 0);
|
||
}
|
||
|
||
function parseOptionsFromText(value, sourceSection = 'optionsText') {
|
||
const text = cleanMultiline(value, 12000);
|
||
const candidateText = /\b(maybe|possibly|options?|features?|ideas?|next moves?|functionality|backlog)\b[:\s]/i.test(text)
|
||
? text.replace(/^[\s\S]*?\b(maybe|possibly|options?|features?|ideas?|next moves?|functionality|backlog)\b[:\s]*/i, '')
|
||
: text;
|
||
const cleanedLines = candidateText.split('\n').map(line => line.replace(/^\s*[-*•\d.)]+\s*/, '').trim()).filter(Boolean);
|
||
const sentenceList = candidateText
|
||
.replace(/\b(maybe|possibly|options?|features?|ideas?|next moves?|functionality|backlog)\s*:/gi, '\n')
|
||
.split(/\n|;|\|/)
|
||
.map(part => part.replace(/^\s*[-*•\d.)]+\s*/, '').trim())
|
||
.filter(Boolean);
|
||
const commaList = sentenceList.length === 1
|
||
? sentenceList[0].split(/,|\s+and\s+|[.!?]\s+/i).map(part => part.trim()).filter(Boolean)
|
||
: [];
|
||
const optionLines = cleanedLines.length >= 2
|
||
? cleanedLines
|
||
: sentenceList.length >= 2
|
||
? sentenceList
|
||
: commaList.length >= 2
|
||
? commaList
|
||
: candidateText.split(/[;|]/).map(part => part.trim()).filter(Boolean);
|
||
return optionLines.slice(0, 24).map((line, index) => {
|
||
const normalized = line
|
||
.replace(/^\s*(maybe|possibly|also|plus|and|or|then|later|eventually|build|add|include|some kind of|kind of)\b\s*/i, '')
|
||
.replace(/\?+$/, '')
|
||
.trim();
|
||
const [rawTitle, ...rest] = normalized.split(/\s*[-–—:]\s+/);
|
||
return {
|
||
id: `option-${index + 1}`,
|
||
title: cleanText(rawTitle || normalized || line, 140),
|
||
description: cleanText(rest.join(' — '), 420),
|
||
provenance: { sourceSection },
|
||
};
|
||
}).filter(item => item.title && !/^(i('| a)?m|i am|we are|i only|want|need)\b/i.test(item.title));
|
||
}
|
||
|
||
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 extractFirstJsonObject(text = '') {
|
||
const start = text.indexOf('{');
|
||
if (start < 0) return '';
|
||
let depth = 0;
|
||
let inString = false;
|
||
let escaped = false;
|
||
for (let index = start; index < text.length; index += 1) {
|
||
const char = text[index];
|
||
if (escaped) {
|
||
escaped = false;
|
||
continue;
|
||
}
|
||
if (char === '\\' && inString) {
|
||
escaped = true;
|
||
continue;
|
||
}
|
||
if (char === '"') {
|
||
inString = !inString;
|
||
continue;
|
||
}
|
||
if (inString) continue;
|
||
if (char === '{') depth += 1;
|
||
if (char === '}') {
|
||
depth -= 1;
|
||
if (depth === 0) return text.slice(start, index + 1);
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function parseEmbeddedRankPayload(value = '') {
|
||
const text = cleanMultiline(value, 12000)
|
||
.replace(/^```(?:json)?\s*/i, '')
|
||
.replace(/```$/i, '')
|
||
.trim();
|
||
const jsonText = text.startsWith('{') && text.endsWith('}') ? text : extractFirstJsonObject(text);
|
||
if (!jsonText) return null;
|
||
try {
|
||
const parsed = JSON.parse(jsonText);
|
||
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);
|
||
const conceptMap = objectFrom(input.conceptMap || featureSet.conceptMap || artifact.conceptMap);
|
||
const snapshot = objectFrom(input.snapshot || featureSet.snapshot || artifact.snapshot || conceptMap.snapshot);
|
||
const source = objectFrom(input.source || featureSet.source || artifact.source);
|
||
return {
|
||
schema: cleanText(input.schema || featureSet.schema || artifact.schema || input.type || 'prioritix-feature-set-v1', 80),
|
||
source: cleanText(input.sourceName || featureSet.sourceName || source.name || artifact.sourceName || 'Scattermind', 80),
|
||
artifactId: cleanText(input.artifactId || input.sourceArtifactId || input.referenceCode || input.reference_code || artifact.id || source.artifactId || conceptMap.artifactId || conceptMap.id || conceptMap.referenceCode || conceptMap.reference_code || snapshot.artifactId || snapshot.id || '', 120),
|
||
snapshotTitle: cleanText(input.snapshotTitle || input.working_name || input.workingName || artifact.snapshotTitle || snapshot.title || snapshot.name || conceptMap.snapshotTitle || conceptMap.working_name || conceptMap.workingName || input.ideaTitle || '', 160),
|
||
conceptMapId: cleanText(input.conceptMapId || artifact.conceptMapId || conceptMap.id || conceptMap.artifactId || input.referenceCode || input.reference_code || conceptMap.referenceCode || conceptMap.reference_code || '', 120),
|
||
originalPrompt: cleanMultiline(input.originalPrompt || input.initialPrompt || input.ideaText || input.prompt || artifact.originalPrompt || source.originalPrompt || snapshot.originalPrompt || snapshot.prompt || conceptMap.originalPrompt || conceptMap.ideaText || input.idea || '', 1200),
|
||
};
|
||
}
|
||
|
||
function lensContent(lens = {}) {
|
||
if (Array.isArray(lens)) return lens;
|
||
if (typeof lens === 'string' || typeof lens === 'number') return String(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 '';
|
||
}
|
||
|
||
function cleanDecisionContext(input = {}) {
|
||
const featureSet = objectFrom(input.featureSet);
|
||
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 = 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,
|
||
featureSet.decisionContext,
|
||
artifact.decisionContext,
|
||
conceptMap.decisionContext,
|
||
structuredContext,
|
||
conceptMap.context,
|
||
featureSet.context,
|
||
artifact.context,
|
||
];
|
||
const textContextGuardrails = guardrailsFromContextText([
|
||
typeof input.context === 'string' ? input.context : '',
|
||
lensContent(riskLens),
|
||
lensContent(constraintsLens),
|
||
conceptMap.risk || conceptMap.whatNotToBuildYet || conceptMap.notYet || '',
|
||
].filter(Boolean).join('\n'));
|
||
return {
|
||
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 || 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 || conceptMap.nonGoals || conceptMap.avoid, 8, 180),
|
||
...collectContextList(contextSources, ['nonGoals', 'nonGoal', 'avoid', 'notYet', 'doNotBuild'], 8),
|
||
...textContextGuardrails.nonGoals,
|
||
], 8),
|
||
assumptions: uniqueList([
|
||
...cleanFlexibleTextList(input.assumptions || featureSet.assumptions || conceptMap.assumptions, 6, 180),
|
||
...collectContextList(contextSources, ['assumptions', 'assumption', 'unknowns', 'openQuestions'], 6),
|
||
...cleanSentenceList(lensContent(assumptionsLens), 6, 180),
|
||
], 6),
|
||
};
|
||
}
|
||
|
||
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 = '') {
|
||
const stop = new Set(['the', 'and', 'with', 'from', 'that', 'this', 'into', 'before', 'after', 'user', 'users', 'idea', 'ideas', 'build', 'first', 'avoid', 'without', 'more', 'full', 'proof', 'manual', '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);
|
||
}
|
||
|
||
function nonGoalConflicts(optionText, decisionContext = {}) {
|
||
const lower = String(optionText || '').toLowerCase();
|
||
return (decisionContext.nonGoals || []).filter(nonGoal => {
|
||
const tokens = meaningfulTokens(nonGoal);
|
||
return tokens.length > 0 && tokens.some(token => {
|
||
if (lower.includes(token)) return true;
|
||
const singular = token.endsWith('ies') ? `${token.slice(0, -3)}y` : token.replace(/(?:es|s)$/, '');
|
||
return singular.length >= 4 && lower.includes(singular);
|
||
});
|
||
});
|
||
}
|
||
|
||
function normalizeFeatureOption(item, index, fallbackId = 'feature', defaultSourceSection = '', defaultRecommendedLane = '') {
|
||
const raw = typeof item === 'string' || typeof item === 'number' ? { action: String(item) } : objectFrom(item);
|
||
const title = cleanText(raw.title || raw.name || raw.action || raw.move || raw.label || '', 140);
|
||
const proofSteps = cleanTextList(raw.proofSteps || raw.proof || raw.validationSteps, 5, 180);
|
||
const dependencies = cleanTextList(raw.dependencies || raw.blockedBy, 5, 120);
|
||
const evidenceNeeded = cleanText(raw.evidenceNeeded || raw.evidence || raw.test || raw.evidenceQuestion || raw.questionToAnswer || '', 260);
|
||
const userValue = cleanText(raw.userValue || raw.value || raw.outcome || raw.why, 260);
|
||
const risk = cleanText(raw.risk || raw.assumption || raw.unknown || '', 220);
|
||
const nextStep = cleanText(raw.nextStep || raw.nextAction || raw.firstStep || raw.manualStep || raw.actionToTake || '', 260);
|
||
const successSignal = cleanText(raw.successSignal || raw.successCriteria || raw.successMetric || raw.greenLight || raw.signalToSee || '', 260);
|
||
const killSignal = cleanText(raw.killSignal || raw.stopSignal || raw.redFlag || raw.failureSignal || raw.cutIf || '', 260);
|
||
const rawLane = cleanText(raw.lane || '', 40);
|
||
const laneLooksLikeHint = Boolean(normalizeLaneHint(rawLane));
|
||
const sourceSection = cleanText(raw.sourceSection || raw.section || raw.origin || (!laneLooksLikeHint ? rawLane : '') || defaultSourceSection, 80);
|
||
const sourceId = cleanText(raw.sourceId || raw.sourceArtifactId || raw.sourceItemId || raw.traceId || raw.id || '', 120);
|
||
const sourceTitle = cleanText(raw.sourceTitle || raw.sourceHeading || raw.lensTitle || raw.heading || '', 140);
|
||
const sourceQuote = cleanMultiline(raw.sourceQuote || raw.sourceExcerpt || raw.evidenceQuote || raw.quote || raw.originalText || raw.rawText || '', 420);
|
||
const recommendedLane = cleanText(raw.recommendedLane || raw.laneHint || raw.suggestedLane || (laneLooksLikeHint ? rawLane : '') || defaultRecommendedLane || '', 40).toLowerCase();
|
||
const descriptionParts = [
|
||
raw.description || raw.brief || '',
|
||
userValue && `User value: ${userValue}`,
|
||
evidenceNeeded && `Evidence needed: ${evidenceNeeded}`,
|
||
risk && `Risk: ${risk}`,
|
||
nextStep && `Next step: ${nextStep}`,
|
||
successSignal && `Success signal: ${successSignal}`,
|
||
killSignal && `Kill signal: ${killSignal}`,
|
||
proofSteps.length && `Proof steps: ${proofSteps.join('; ')}`,
|
||
dependencies.length && `Dependencies: ${dependencies.join(', ')}`,
|
||
].filter(Boolean);
|
||
return {
|
||
id: cleanText(raw.id || raw.key || `${fallbackId}-${index + 1}`, 80) || `${fallbackId}-${index + 1}`,
|
||
title,
|
||
description: cleanText(descriptionParts.join(' '), 760),
|
||
factors: { userValue, evidenceNeeded, risk, nextStep, successSignal, killSignal, proofSteps, dependencies, recommendedLane, metricHints: cleanMetricHints(raw) },
|
||
provenance: {
|
||
sourceId,
|
||
sourceSection,
|
||
sourceTitle,
|
||
sourceQuote,
|
||
},
|
||
};
|
||
}
|
||
|
||
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 compactCandidateGroup(group = []) {
|
||
return group.filter(entry => Array.isArray(entry?.items) && entry.items.length > 0);
|
||
}
|
||
|
||
function buildOrderSectionGroup(buildOrder = {}, baseSection = 'buildOrder') {
|
||
const source = objectFrom(buildOrder);
|
||
return compactCandidateGroup([
|
||
{ items: source.doFirst || source.do_first || source.buildFirst || source.buildNow || source.now, sourceSection: `${baseSection}.doFirst`, defaultLane: 'do-first' },
|
||
{ items: source.validateNext || source.validate_next || source.testNext || source.testManually || source.validation, sourceSection: `${baseSection}.validateNext`, defaultLane: 'validate-next' },
|
||
{ items: source.defer || source.deferred || source.later || source.afterProof, sourceSection: `${baseSection}.defer`, defaultLane: 'defer' },
|
||
{ items: source.park || source.parkingLot || source.parked || source.probablyNoise || source.noise, sourceSection: `${baseSection}.park`, defaultLane: 'park' },
|
||
]);
|
||
}
|
||
|
||
function normalizeCandidateGroup(group = []) {
|
||
const options = group.flatMap(entry => {
|
||
const fallbackId = entry.sourceSection.toLowerCase().includes('action') ? 'action' : 'feature';
|
||
return (entry.items || []).slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, fallbackId, entry.sourceSection, entry.defaultLane));
|
||
}).filter(item => item.title).slice(0, 24);
|
||
return normalizeOptionIds(options);
|
||
}
|
||
|
||
function sentenceFragments(text = '') {
|
||
return cleanMultiline(text, 4000)
|
||
.replace(/\s+(build first|start here|ship first|test manually|validate next|defer|probably noise|park|do not build yet|don't build yet)\s*:/gi, '\n$1:')
|
||
.split(/\n|;|\s+[•-]\s+/)
|
||
.map(part => part.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function titleFromBuildOrderFragment(value = '') {
|
||
const cleaned = cleanText(value.replace(/^(build first|start here|ship first|test manually|validate next|defer|probably noise|park|do not build yet|don't build yet)\s*:\s*/i, ''), 220);
|
||
const first = cleaned.split(/\s[-–—:]\s|\.\s/)[0] || cleaned;
|
||
return cleanText(first, 120);
|
||
}
|
||
|
||
function laneFromBuildOrderLabel(fragment = '') {
|
||
if (/^(build first|start here|ship first)\s*:/i.test(fragment)) return 'do-first';
|
||
if (/^(test manually|validate next)\s*:/i.test(fragment)) return 'validate-next';
|
||
if (/^(defer|do not build yet|don't build yet)\s*:/i.test(fragment)) return 'defer';
|
||
if (/^(probably noise|park)\s*:/i.test(fragment)) return 'park';
|
||
return '';
|
||
}
|
||
|
||
function optionsFromBuildOrderText(text = '', sourceSection = 'concept-map.lenses.channel', sourceTitle = 'Build Order') {
|
||
const fragments = sentenceFragments(text);
|
||
const labelled = fragments.filter(fragment => laneFromBuildOrderLabel(fragment));
|
||
return labelled.map((fragment, index) => {
|
||
const lane = laneFromBuildOrderLabel(fragment);
|
||
return {
|
||
id: `build-order-${index + 1}`,
|
||
action: titleFromBuildOrderFragment(fragment),
|
||
why: fragment.replace(/^\s*[^:]{1,40}:\s*/, '').trim(),
|
||
evidence: /test|validate|proof|prove|signal|ask|show/i.test(fragment) ? fragment : (lane === 'do-first' ? 'Prove this first move manually before adding product machinery.' : ''),
|
||
suggestedLane: lane,
|
||
rankerHints: lane === 'do-first'
|
||
? { value: 8, effort: 2, confidence: 7, urgency: 7, risk: 2 }
|
||
: lane === 'validate-next'
|
||
? { value: 7, effort: 3, confidence: 6, urgency: 5, risk: 3 }
|
||
: undefined,
|
||
sourceSection,
|
||
sourceItemId: `${sourceSection}#${index + 1}`,
|
||
sourceTitle,
|
||
sourceExcerpt: fragment,
|
||
};
|
||
}).filter(item => item.action);
|
||
}
|
||
|
||
function optionsFromBody(body = {}) {
|
||
const featureSet = objectFrom(body.featureSet);
|
||
const conceptMap = objectFrom(body.conceptMap || featureSet.conceptMap);
|
||
const conceptMapLenses = objectFrom(conceptMap.lenses || body.lenses || featureSet.lenses);
|
||
const buildOrderLens = objectFrom(conceptMapLenses.channel || conceptMapLenses.buildOrder || conceptMap.buildOrder);
|
||
const directCandidateGroup = compactCandidateGroup([
|
||
{ items: body.features, sourceSection: 'features' },
|
||
{ items: featureSet.features, sourceSection: 'feature-set.features' },
|
||
{ items: body.actions, sourceSection: 'actions' },
|
||
{ items: featureSet.actions, sourceSection: 'feature-set.actions' },
|
||
{ items: body.nextMoves, sourceSection: 'nextMoves' },
|
||
{ items: featureSet.nextMoves, sourceSection: 'feature-set.nextMoves' },
|
||
{ items: body.candidates, sourceSection: 'candidates' },
|
||
{ items: featureSet.candidates, sourceSection: 'feature-set.candidates' },
|
||
]);
|
||
const conceptMapCandidateGroup = compactCandidateGroup([
|
||
{ items: conceptMap.nextActions, sourceSection: 'concept-map.nextActions' },
|
||
{ items: conceptMap.nextMoves, sourceSection: 'concept-map.nextMoves' },
|
||
{ items: conceptMap.features, sourceSection: 'concept-map.features' },
|
||
{ items: conceptMap.candidates, sourceSection: 'concept-map.candidates' },
|
||
{ items: conceptMap.validateNext || conceptMap.validate || conceptMap.validation, sourceSection: 'concept-map.validateNext', defaultLane: 'validate-next' },
|
||
{ items: conceptMap.deferred || conceptMap.defer || conceptMap.later, sourceSection: 'concept-map.deferred', defaultLane: 'defer' },
|
||
{ items: conceptMap.parkingLot || conceptMap.park || conceptMap.parked, sourceSection: 'concept-map.parkingLot', defaultLane: 'park' },
|
||
]);
|
||
const groupedCandidates = [
|
||
...directCandidateGroup,
|
||
...conceptMapCandidateGroup,
|
||
...buildOrderSectionGroup(body.buildOrder, 'buildOrder'),
|
||
...buildOrderSectionGroup(featureSet.buildOrder, 'feature-set.buildOrder'),
|
||
...buildOrderSectionGroup(conceptMap.buildOrder, 'concept-map.buildOrder'),
|
||
];
|
||
if (groupedCandidates.length) return normalizeCandidateGroup(groupedCandidates);
|
||
const buildOrderText = lensContent(conceptMapLenses.channel)
|
||
|| lensContent(conceptMapLenses.buildOrder)
|
||
|| lensContent(conceptMap.buildOrder)
|
||
|| buildOrderLens.content
|
||
|| buildOrderLens.text
|
||
|| '';
|
||
const buildOrderSourceTitle = cleanText(
|
||
objectFrom(conceptMapLenses.channel).title
|
||
|| objectFrom(conceptMapLenses.buildOrder).title
|
||
|| objectFrom(conceptMap.buildOrder).title
|
||
|| buildOrderLens.title
|
||
|| 'Build Order',
|
||
140
|
||
);
|
||
const buildOrderOptions = optionsFromBuildOrderText(buildOrderText, 'concept-map.lenses.channel', buildOrderSourceTitle);
|
||
if (buildOrderOptions.length) return normalizeCandidateGroup([{ items: buildOrderOptions, sourceSection: 'concept-map.lenses.channel' }]);
|
||
if (Array.isArray(body.options)) {
|
||
return normalizeOptionIds(body.options.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, 'option', 'options')).filter(item => item.title));
|
||
}
|
||
const fallbackText = body.optionsText || featureSet.optionsText || conceptMap.optionsText || body.idea || body.ideaText || '';
|
||
const fallbackSourceSection = body.optionsText || featureSet.optionsText || conceptMap.optionsText
|
||
? 'optionsText'
|
||
: body.idea
|
||
? 'idea'
|
||
: body.ideaText
|
||
? 'ideaText'
|
||
: 'optionsText';
|
||
return normalizeOptionIds(parseOptionsFromText(fallbackText, fallbackSourceSection));
|
||
}
|
||
|
||
function scoreOption(option, mode, context = '', decisionContext = {}) {
|
||
const factors = option.factors || {};
|
||
const text = `${option.title} ${option.description} ${factors.userValue || ''} ${factors.evidenceNeeded || ''} ${factors.risk || ''} ${(factors.proofSteps || []).join(' ')} ${context}`;
|
||
const effortHits = hits(text, wordSets.effort);
|
||
const riskHits = hits(text, wordSets.risk);
|
||
const coreLoopHits = hits(text, ['ranked feedback map', 'feedback map', 'pasted feature', 'feature lists', 'first-pass', 'decision brief', 'expert reflections']);
|
||
const bridgeHits = hits(text, ['snapshot', 'concept map', 'feature set', 'build order', 'rank-ready', 'provenance', 'next moves']);
|
||
const proofHits = hits(text, ['evidence needed', 'proof steps', 'manual', 'test with', 'validate', '3 users', 'interview', 'mock', 'prototype']);
|
||
const swampHits = hits(text, ['dashboard', 'workspace', 'workspaces', 'auth', 'accounts', 'billing', 'subscription', 'team voting', 'collaboration', 'admin']);
|
||
const dependencyPenalty = Math.min(2.2, (factors.dependencies || []).length * 0.45);
|
||
const laneHint = factors.recommendedLane || '';
|
||
const normalizedLaneHint = normalizeLaneHint(laneHint);
|
||
const conflicts = nonGoalConflicts(`${option.title} ${option.description}`, decisionContext);
|
||
const nonGoalPenalty = Math.min(14, conflicts.length * 7);
|
||
const laneBoost = /do|first|now|build/.test(laneHint) ? 1.35 : /validate|test|proof/.test(laneHint) ? 0.35 : /defer|park|cut/.test(laneHint) ? -0.75 : 0;
|
||
const lanePenalty = normalizedLaneHint === 'park' ? 18 : normalizedLaneHint === 'defer' ? 9 : 0;
|
||
const heuristicMetrics = {
|
||
value: Math.min(10, 4.8 + hits(text, wordSets.value) * 0.9 + coreLoopHits * 0.8 + bridgeHits * 0.75 + proofHits * 0.2 + hits(context, wordSets.value) * 0.15),
|
||
feasibility: Math.max(1, Math.min(10, 8.2 - effortHits * 0.8 - riskHits * 0.35 - dependencyPenalty - conflicts.length * 1.1 + Math.min(1.1, coreLoopHits * 0.28 + bridgeHits * 0.18 + proofHits * 0.12))),
|
||
confidence: Math.max(1, Math.min(10, 5.8 + coreLoopHits * 0.35 + bridgeHits * 0.28 + proofHits * 0.32 + hits(text, ['manual', 'existing', 'already', 'simple', 'clear', 'known']) * 0.7 - hits(text, ['maybe', 'unknown', 'new market', 'all users']) * 0.9)),
|
||
urgency: Math.min(10, 4.9 + hits(text, wordSets.urgency) * 0.9),
|
||
revenue: Math.min(10, 3.8 + hits(text, wordSets.revenue) * 1.05),
|
||
novelty: Math.min(10, 4.1 + hits(text, wordSets.novelty) * 0.95),
|
||
risk: Math.min(10, 2.5 + riskHits * 1.1 + Math.max(0, effortHits - 2) * 0.45 + swampHits * 0.2 + dependencyPenalty + conflicts.length * 1.25),
|
||
};
|
||
const hinted = factors.metricHints || {};
|
||
const hintedFeasibility = Number.isFinite(hinted.effort) ? 11 - hinted.effort : undefined;
|
||
const metrics = {
|
||
value: blendMetric(heuristicMetrics.value, hinted.value),
|
||
feasibility: blendMetric(heuristicMetrics.feasibility, hintedFeasibility),
|
||
confidence: blendMetric(heuristicMetrics.confidence, hinted.confidence),
|
||
urgency: blendMetric(heuristicMetrics.urgency, hinted.urgency),
|
||
revenue: blendMetric(heuristicMetrics.revenue, hinted.revenue),
|
||
novelty: blendMetric(heuristicMetrics.novelty, hinted.novelty),
|
||
risk: blendMetric(heuristicMetrics.risk, hinted.risk),
|
||
};
|
||
const weights = mode.weights;
|
||
const weighted = Object.entries(weights).reduce((sum, [key, weight]) => sum + metrics[key] * weight, 0);
|
||
const possible = Object.entries(weights).reduce((sum, [_key, weight]) => sum + Math.abs(weight) * 10, 0);
|
||
const score = Math.max(0, Math.min(100, Math.round(((weighted + laneBoost) + Math.abs(Math.min(0, weights.risk || 0)) * 10) / possible * 100) - lanePenalty - nonGoalPenalty));
|
||
return { ...metrics, score, nonGoalConflicts: conflicts };
|
||
}
|
||
|
||
function normalizeLaneHint(value = '') {
|
||
const hint = cleanText(value, 40).toLowerCase().replace(/_/g, '-');
|
||
if (/^(do|do-first|first|build|build-now|now)$/.test(hint)) return 'do';
|
||
if (/^(validate|validate-next|test|test-next|proof|evidence)$/.test(hint)) return 'test';
|
||
if (/^(defer|later|sequence-later|after-proof)$/.test(hint)) return 'defer';
|
||
if (/^(park|cut|drop|icebox|not-now)$/.test(hint)) return 'park';
|
||
return '';
|
||
}
|
||
|
||
function laneFor(option, rankIndex, total) {
|
||
const hintedLane = normalizeLaneHint(option.factors?.recommendedLane || '');
|
||
// Ranker should defend build order, not blindly obey Scattermind. Positive
|
||
// hints can nudge scoring, but explicit negative hints and source non-goals
|
||
// are safety rails: if the source already marked something as not-now, or
|
||
// if the candidate conflicts with the source guardrails, never promote it
|
||
// into the active proof slice just because keyword scoring liked it.
|
||
if (hintedLane === 'park') return { id: 'park', label: 'Park / cut', action: 'Keep out of the active plan', source: 'hint' };
|
||
if (hintedLane === 'defer') return { id: 'defer', label: 'Defer', action: 'Sequence after proof', source: 'hint' };
|
||
if (option.metrics?.nonGoalConflicts?.length) return { id: 'defer', label: 'Defer', action: 'Resolve source guardrail first', source: 'source-non-goal' };
|
||
if (rankIndex === 0) return { id: 'do', label: 'Do first', action: 'Build/test now' };
|
||
if (hintedLane === 'test') return { id: 'test', label: 'Validate next', action: 'Find evidence', source: 'hint' };
|
||
if (rankIndex < Math.max(2, Math.ceil(total * 0.32))) return { id: 'test', label: 'Validate next', action: 'Find evidence' };
|
||
if (rankIndex >= Math.max(2, Math.floor(total * 0.72))) return { id: 'park', label: 'Park / cut', action: 'Keep out of the active plan' };
|
||
return { id: 'defer', label: 'Defer', action: 'Sequence after proof' };
|
||
}
|
||
|
||
function scoreDriversFor(option) {
|
||
const m = option.metrics || {};
|
||
const drivers = [];
|
||
if (m.nonGoalConflicts?.length) drivers.push('source guardrail conflict');
|
||
if (m.value >= 7) drivers.push('strong user value');
|
||
if (m.feasibility >= 7) drivers.push('low delivery drag');
|
||
if (m.confidence >= 7) drivers.push('clear proof path');
|
||
if (m.revenue >= 6.5) drivers.push('buyer signal');
|
||
if (m.novelty >= 6.5) drivers.push('differentiated angle');
|
||
if (option.factors?.evidenceNeeded) drivers.push('explicit evidence needed');
|
||
if ((option.factors?.dependencies || []).length === 0) drivers.push('few dependencies');
|
||
if (m.risk >= 6.5) drivers.push('high assumption risk');
|
||
return drivers.slice(0, 4);
|
||
}
|
||
|
||
function reasonFor(option) {
|
||
const m = option.metrics;
|
||
const drivers = scoreDriversFor(option).filter(driver => driver !== 'source guardrail conflict' && driver !== 'high assumption risk');
|
||
if (m.nonGoalConflicts?.length) return `it conflicts with the source non-goal “${m.nonGoalConflicts[0]}”, so it should not lead the build order`;
|
||
if (option.lane?.id === 'do' && /snapshot|concept map|feature set|build order|rank/i.test(`${option.title} ${option.description}`)) return 'it strengthens the Scattermind → Ranker bridge instead of inventing a generic workspace';
|
||
if (drivers.length >= 2) return `it wins on ${drivers.join(', ')} while staying inside the current proof slice`;
|
||
if (option.factors?.evidenceNeeded && m.confidence >= 6.4) return 'it names the evidence needed, so the next move can be tested instead of guessed';
|
||
if (m.revenue >= 6.4) return 'it has a clearer buyer or money signal than the rest of the list';
|
||
if (m.risk >= 6.5) return 'it is important enough to investigate, but carries assumption risk that should be tested before build';
|
||
if (m.novelty >= 6.7) return 'it is more differentiated than the safe options, but still needs proof';
|
||
return 'it has the best balanced tradeoff across value, effort, confidence, and timing';
|
||
}
|
||
|
||
function concernFor(option) {
|
||
const m = option.metrics;
|
||
if (m.nonGoalConflicts?.length) return `Source context says not to do this yet: ${m.nonGoalConflicts.join('; ')}.`;
|
||
if ((option.factors?.dependencies || []).length >= 3) return 'Too many prerequisites. Split the proof slice before treating this as build-ready.';
|
||
if (option.factors?.risk) return `The explicit risk is: ${option.factors.risk}.`;
|
||
if (m.risk >= 6.5) return 'The hidden risk is pretending this is ready to build before the core assumption is proven.';
|
||
if (m.feasibility <= 4.5) return 'The likely trap is scope creep: this may need too much machinery for an MVP.';
|
||
if (m.confidence <= 4.5) return 'Evidence looks thin. Treat this as a question, not a roadmap item.';
|
||
if (m.revenue <= 4 && m.value >= 6) return 'Useful does not automatically mean sellable. Check willingness to pay or repeat use.';
|
||
return 'The main risk is sequencing: do it only if it supports the first useful proof.';
|
||
}
|
||
|
||
function evidenceQuestionFor(option) {
|
||
if (!option) return '';
|
||
if (option.factors?.evidenceNeeded) return option.factors.evidenceNeeded;
|
||
if (option.lane?.id === 'park') return `What would make “${option.title}” worth reopening later?`;
|
||
if (option.metrics?.revenue >= 6.4) return `Will a real buyer ask for or pay for “${option.title}” before it is polished?`;
|
||
if (option.metrics?.risk >= 6.2) return `What is the cheapest test that could disprove “${option.title}”?`;
|
||
return `Can 3–5 target users understand and act on “${option.title}” without extra explanation?`;
|
||
}
|
||
|
||
function nextStepFor(option) {
|
||
if (!option) return '';
|
||
if (option.factors?.nextStep) return option.factors.nextStep;
|
||
if (option.lane?.id === 'do') return `Run one manual proof of “${option.title}” before building supporting machinery.`;
|
||
if (option.lane?.id === 'test') return `Design the smallest evidence test for “${option.title}” and collect signal from real users.`;
|
||
if (option.lane?.id === 'defer') return `Keep “${option.title}” sequenced after the active proof; do not parallel-build it.`;
|
||
return `Park “${option.title}” unless new evidence changes the decision.`;
|
||
}
|
||
|
||
function successSignalFor(option) {
|
||
if (!option) return '';
|
||
if (option.factors?.successSignal) return option.factors.successSignal;
|
||
if (option.metrics?.revenue >= 6.4) return 'A real prospect asks for the outcome, accepts a price, or requests the next step.';
|
||
if (option.lane?.id === 'do') return 'At least 2 of 3 real users can name why this should be first and what they would do next.';
|
||
if (option.lane?.id === 'test') return 'The test produces a clear yes/no learning, not polite interest.';
|
||
return 'New evidence makes this more urgent than the current active lane.';
|
||
}
|
||
|
||
function killSignalFor(option) {
|
||
if (!option) return '';
|
||
if (option.factors?.killSignal) return option.factors.killSignal;
|
||
if (option.metrics?.nonGoalConflicts?.length) return 'It still conflicts with the source guardrails after review.';
|
||
if (option.metrics?.feasibility <= 4.5) return 'The proof slice needs platform work before any user signal exists.';
|
||
return 'People understand the idea but do not take, request, or value the next step.';
|
||
}
|
||
|
||
function scoringNotesFor(option) {
|
||
const notes = [];
|
||
const m = option.metrics || {};
|
||
if (option.factors?.evidenceNeeded) notes.push('Boosted because it names evidence to collect.');
|
||
if (m.nonGoalConflicts?.length) notes.push('Penalized because it conflicts with source guardrails.');
|
||
if (m.feasibility >= 7) notes.push('Boosted for low delivery drag.');
|
||
if (m.value >= 7) notes.push('Boosted for user value.');
|
||
if (m.revenue >= 6.5) notes.push('Boosted for buyer signal.');
|
||
if (m.risk >= 6.5) notes.push('Flagged as assumption-heavy.');
|
||
if ((option.factors?.dependencies || []).length >= 2) notes.push('Penalized for dependencies.');
|
||
return notes.slice(0, 4);
|
||
}
|
||
|
||
function whatWouldChangeRanking(top, second, risky) {
|
||
if (!top) return ['Add at least two concrete next moves with evidence needed.'];
|
||
const changes = [];
|
||
if (top.factors?.evidenceNeeded) changes.push(`If evidence fails for “${top.title}” (${top.factors.evidenceNeeded}), move it out of Do first.`);
|
||
else changes.push(`If “${top.title}” cannot name a cheap proof step, demote it until the evidence question is clear.`);
|
||
if (second) changes.push(`If “${second.title}” gets stronger proof or meaningfully lower effort, it can overtake the current first move.`);
|
||
if (risky && risky.id !== top.id) changes.push(`If the riskiest assumption around “${risky.title}” is answered cheaply, re-rank before building around it.`);
|
||
changes.push('If the source Concept Map adds new constraints or non-goals, re-run the order instead of editing around stale context.');
|
||
return changes.slice(0, 4);
|
||
}
|
||
|
||
function createDecisionBrief({ idea, context, mode, ranked, provenance, decisionContext }) {
|
||
const top = ranked[0];
|
||
const second = ranked[1];
|
||
const risky = ranked.slice().sort((a, b) => b.metrics.risk - a.metrics.risk)[0];
|
||
const deferred = ranked.filter(item => ['defer', 'park'].includes(item.lane.id)).slice(0, 3);
|
||
const sourceLabel = [provenance?.snapshotTitle, provenance?.artifactId].filter(Boolean).join(' · ');
|
||
const theme = top ? `The strongest signal is “${top.title}” because ${reasonFor(top)}.` : 'The list needs at least two options before ranking becomes useful.';
|
||
const quickGlance = top ? {
|
||
topPick: top.title,
|
||
topLane: top.lane?.label || '',
|
||
whyThisWins: reasonFor(top),
|
||
nextAction: nextStepFor(top),
|
||
evidenceQuestion: evidenceQuestionFor(top),
|
||
biggestTrap: concernFor(top),
|
||
doNotBuildYet: deferred.slice(0, 2).map(item => item.title),
|
||
sourceTrace: {
|
||
sourceSection: top.provenance?.sourceSection || '',
|
||
sourceId: top.provenance?.sourceId || '',
|
||
sourceTitle: top.provenance?.sourceTitle || '',
|
||
sourceQuote: top.provenance?.sourceQuote || '',
|
||
},
|
||
} : null;
|
||
const assumptions = [
|
||
...(decisionContext?.assumptions || []),
|
||
...(decisionContext?.constraints || []).map(item => `Constraint: ${item}`),
|
||
...(decisionContext?.nonGoals || []).map(item => `Non-goal: ${item}`),
|
||
].slice(0, 6);
|
||
return {
|
||
headline: top ? `Start with ${top.title}` : 'Add options to get a ranked feedback map',
|
||
summary: `${theme}${second ? ` “${second.title}” is the nearest follow-up, not a parallel first step.` : ''}${sourceLabel ? ` Source: ${sourceLabel}.` : ''}`,
|
||
quickGlance,
|
||
source: provenance ? {
|
||
schema: provenance.schema,
|
||
source: provenance.source,
|
||
artifactId: provenance.artifactId,
|
||
snapshotTitle: provenance.snapshotTitle,
|
||
conceptMapId: provenance.conceptMapId,
|
||
originalPromptExcerpt: cleanText(provenance.originalPrompt, 260),
|
||
} : null,
|
||
expertReflections: [
|
||
{
|
||
lens: 'Product expert',
|
||
text: top ? `The ranking says the first job is not to build more surface area; it is to prove the highest-signal option. ${mode.next}` : 'A product expert would ask for concrete alternatives before giving serious advice.',
|
||
},
|
||
{
|
||
lens: 'Scattermind simplifier',
|
||
text: deferred.length ? `Park ${deferred.map(item => `“${item.title}”`).join(', ')} for now. Not bad ideas — just mental tabs you do not need open while testing the first move.` : 'The list is already narrow. Keep it that way; do not add a fake backlog around a simple decision.',
|
||
},
|
||
{
|
||
lens: 'Structured operator',
|
||
text: risky ? `The decision quality depends on one assumption: ${risky.title}. What evidence would make this move up or down the list? Write that before committing resources.` : 'Expose the criteria, the confidence, and the thing that would change the decision. That is what makes this defensible.',
|
||
},
|
||
],
|
||
next48Hours: top ? [
|
||
provenance?.artifactId ? `Open the source artifact (${provenance.artifactId}) and mark “${top.title}” as the defended first move.` : `Write a one-paragraph test for “${top.title}”.`,
|
||
top.factors?.evidenceNeeded ? `Evidence to collect: ${top.factors.evidenceNeeded}.` : 'Name the evidence that would make this decision obviously right or wrong.',
|
||
'Put it in front of 3–5 real people or run it manually once.',
|
||
`Do not touch ${deferred[0] ? `“${deferred[0].title}”` : 'the parked ideas'} until the first signal is real.`,
|
||
] : ['Paste 3–10 options.', 'Choose what the ranking should care about.', 'Run the first-pass judgement.'],
|
||
assumptions,
|
||
whatWouldChangeRanking: whatWouldChangeRanking(top, second, risky),
|
||
caution: 'This is first-pass judgement, not an oracle. Change the criteria if the context changes.',
|
||
};
|
||
}
|
||
|
||
function rankConfidenceFor(ranked = []) {
|
||
if (ranked.length < 2) return { level: 'low', reason: 'There are not enough comparable options for a confident ranking.' };
|
||
const gap = Math.abs((ranked[0].metrics?.score || 0) - (ranked[1].metrics?.score || 0));
|
||
if (gap <= 3) return { level: 'close call', reason: `The top two options are only ${gap} points apart, so treat the winner as a sequencing bet, not a law.` };
|
||
if (gap <= 8) return { level: 'medium', reason: `The top option leads by ${gap} points, but the follow-up is still worth rechecking after one proof cycle.` };
|
||
return { level: 'strong', reason: `The top option leads by ${gap} points and has the clearest first-proof profile.` };
|
||
}
|
||
|
||
function closeCallsFor(ranked = []) {
|
||
const calls = [];
|
||
for (let index = 0; index < ranked.length - 1; index += 1) {
|
||
const current = ranked[index];
|
||
const next = ranked[index + 1];
|
||
const gap = Math.abs((current.metrics?.score || 0) - (next.metrics?.score || 0));
|
||
if (gap <= 5) calls.push({
|
||
pair: [current.title, next.title],
|
||
gap,
|
||
note: `“${current.title}” barely beats “${next.title}”; rerank if new evidence changes effort, confidence, or buyer signal.`,
|
||
});
|
||
}
|
||
return calls.slice(0, 3);
|
||
}
|
||
|
||
function compactBuildItems(items = []) {
|
||
return items.map(item => ({
|
||
id: item.id,
|
||
title: item.title,
|
||
reason: item.reason,
|
||
nextStep: item.nextStep,
|
||
evidenceQuestion: item.evidenceQuestion,
|
||
successSignal: item.successSignal,
|
||
killSignal: item.killSignal,
|
||
concern: item.concern,
|
||
sourceSection: item.provenance?.sourceSection || '',
|
||
sourceId: item.provenance?.sourceId || '',
|
||
sourceTitle: item.provenance?.sourceTitle || '',
|
||
sourceQuote: item.provenance?.sourceQuote || '',
|
||
laneSource: item.lane?.source || 'ranked',
|
||
score: item.metrics?.score ?? null,
|
||
confidence: item.metrics?.confidence ?? null,
|
||
}));
|
||
}
|
||
|
||
function handoffReadinessFor({ ranked = [], provenance = {}, warnings = [], expectsSourceTrace = false }) {
|
||
const uniqueWarnings = [...new Set(warnings)];
|
||
const activeItems = ranked.filter(item => ['do', 'test'].includes(item.lane?.id));
|
||
const guardrailWarnings = uniqueWarnings.filter(item => /^active item .* conflicts with source non-goals/i.test(item));
|
||
const sourceWarnings = uniqueWarnings.filter(item => /^missing (source section|original prompt provenance)/i.test(item));
|
||
const evidenceWarnings = uniqueWarnings.filter(item => /^missing evidence needed for active item/i.test(item));
|
||
const duplicateWarnings = uniqueWarnings.filter(item => /^duplicate source id/i.test(item));
|
||
const missingArtifact = uniqueWarnings.includes('missing source artifact id');
|
||
const blockers = [
|
||
...guardrailWarnings,
|
||
...(expectsSourceTrace ? sourceWarnings : []),
|
||
...(expectsSourceTrace ? evidenceWarnings : []),
|
||
];
|
||
const nextChecks = [];
|
||
|
||
if (guardrailWarnings.length) nextChecks.push('Resolve source non-goal conflicts before treating any active item as build-ready.');
|
||
if (expectsSourceTrace && sourceWarnings.length) nextChecks.push('Attach source section / prompt provenance from the Scattermind artifact.');
|
||
if (expectsSourceTrace && evidenceWarnings.length) nextChecks.push('Add evidenceNeeded to every Do first / Validate next candidate before handoff.');
|
||
if (missingArtifact) nextChecks.push(expectsSourceTrace ? 'Attach the Scattermind artifact id so the build order can be traced back.' : 'Attach a source artifact id if this came from Scattermind rather than a plain paste.');
|
||
if (duplicateWarnings.length) nextChecks.push('Review normalized duplicate IDs before downstream tools store references.');
|
||
if (!nextChecks.length && activeItems.length) nextChecks.push('Use the Do first item as the only active build slice, then rerank after evidence changes.');
|
||
|
||
const status = guardrailWarnings.length
|
||
? 'blocked'
|
||
: blockers.length
|
||
? 'needs-source-context'
|
||
: uniqueWarnings.length
|
||
? 'usable-with-warnings'
|
||
: 'ready';
|
||
const labels = {
|
||
ready: 'Ready for Ranker handoff',
|
||
'usable-with-warnings': 'Usable, but provenance is thin',
|
||
'needs-source-context': 'Needs source context before handoff',
|
||
blocked: 'Blocked by source guardrails',
|
||
};
|
||
const summaries = {
|
||
ready: `Active order is traceable and evidence-shaped for ${activeItems.length} active item${activeItems.length === 1 ? '' : 's'}.`,
|
||
'usable-with-warnings': 'Ranking can be read now, but downstream bridge consumers should review the warnings before storing it as a defended handoff.',
|
||
'needs-source-context': 'A Scattermind-shaped artifact is present, but active items are missing trace or evidence fields needed to defend the order later.',
|
||
blocked: 'An active item conflicts with source non-goals; do not continue until the guardrail is resolved or the item moves out of the active lanes.',
|
||
};
|
||
|
||
return {
|
||
status,
|
||
label: labels[status],
|
||
summary: summaries[status],
|
||
blockers,
|
||
nextChecks: nextChecks.slice(0, 5),
|
||
activeItemCount: activeItems.length,
|
||
warningCount: uniqueWarnings.length,
|
||
sourceComplete: Boolean(!expectsSourceTrace || (provenance?.originalPrompt && activeItems.every(item => item.provenance?.sourceSection))),
|
||
};
|
||
}
|
||
|
||
function createHandoffContract({ ranked, provenance, decisionContext }) {
|
||
const warnings = [];
|
||
const expectsSourceTrace = Boolean(provenance?.artifactId || provenance?.conceptMapId || provenance?.snapshotTitle);
|
||
if (!provenance?.artifactId && provenance?.originalPrompt) warnings.push('missing source artifact id');
|
||
if (expectsSourceTrace && !provenance?.originalPrompt) warnings.push('missing original prompt provenance');
|
||
|
||
const itemTrace = ranked.map(item => {
|
||
if (expectsSourceTrace && !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 (expectsSourceTrace && !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('; ')}`);
|
||
return {
|
||
id: item.id,
|
||
title: item.title,
|
||
lane: item.lane?.id || 'defer',
|
||
sourceSection: item.provenance?.sourceSection || '',
|
||
sourceId: item.provenance?.sourceId || '',
|
||
sourceTitle: item.provenance?.sourceTitle || '',
|
||
sourceQuote: item.provenance?.sourceQuote || '',
|
||
originalId: item.provenance?.originalId || '',
|
||
idNormalized: Boolean(item.provenance?.idNormalized),
|
||
evidenceNeeded: item.factors?.evidenceNeeded || '',
|
||
nextStep: item.nextStep || '',
|
||
successSignal: item.successSignal || '',
|
||
killSignal: item.killSignal || '',
|
||
nonGoalConflicts: item.metrics?.nonGoalConflicts || [],
|
||
};
|
||
});
|
||
const uniqueWarnings = [...new Set(warnings)];
|
||
|
||
return {
|
||
schema: 'rank-feedback-result-v1',
|
||
source: {
|
||
schema: provenance?.schema || '',
|
||
source: provenance?.source || '',
|
||
artifactId: provenance?.artifactId || '',
|
||
snapshotTitle: provenance?.snapshotTitle || '',
|
||
conceptMapId: provenance?.conceptMapId || '',
|
||
originalPromptExcerpt: cleanText(provenance?.originalPrompt || '', 260),
|
||
hasOriginalPrompt: Boolean(provenance?.originalPrompt),
|
||
requiresSourceTrace: expectsSourceTrace,
|
||
},
|
||
decisionContext: {
|
||
targetAudience: decisionContext?.targetAudience || '',
|
||
constraints: decisionContext?.constraints || [],
|
||
nonGoals: decisionContext?.nonGoals || [],
|
||
assumptions: decisionContext?.assumptions || [],
|
||
},
|
||
itemTrace,
|
||
warnings: uniqueWarnings,
|
||
readiness: handoffReadinessFor({ ranked, provenance, warnings: uniqueWarnings, expectsSourceTrace }),
|
||
};
|
||
}
|
||
|
||
app.post('/api/rank-feedback', (req, res) => {
|
||
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(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) }))
|
||
.sort((a, b) => b.metrics.score - a.metrics.score || b.metrics.value - a.metrics.value || a.metrics.risk - b.metrics.risk)
|
||
.map((option, index, arr) => {
|
||
const lane = laneFor(option, index, arr.length);
|
||
const rankedOption = { ...option, rank: index + 1, lane };
|
||
return {
|
||
...rankedOption,
|
||
reason: reasonFor(rankedOption),
|
||
concern: concernFor(rankedOption),
|
||
nextStep: nextStepFor(rankedOption),
|
||
evidenceQuestion: evidenceQuestionFor(rankedOption),
|
||
successSignal: successSignalFor(rankedOption),
|
||
killSignal: killSignalFor(rankedOption),
|
||
scoreDrivers: scoreDriversFor(rankedOption),
|
||
scoringNotes: scoringNotesFor(rankedOption),
|
||
};
|
||
});
|
||
const brief = createDecisionBrief({ idea, context, mode, ranked: options, provenance, decisionContext });
|
||
const handoff = createHandoffContract({ ranked: options, provenance, decisionContext });
|
||
res.json({
|
||
ok: true,
|
||
mode: { id: modeId in judgementModes ? modeId : 'progress', label: mode.label },
|
||
input: { idea, context, optionCount: options.length, provenance, decisionContext, embeddedPayloadSource: body._embeddedPayloadSource || '' },
|
||
ranked: options,
|
||
brief,
|
||
rankConfidence: rankConfidenceFor(options),
|
||
closeCalls: closeCallsFor(options),
|
||
handoff,
|
||
availableModes: Object.entries(judgementModes).map(([id, item]) => ({ id, label: item.label })),
|
||
buildOrder: {
|
||
doFirst: options.filter(item => item.lane.id === 'do').map(item => item.id),
|
||
validateNext: options.filter(item => item.lane.id === 'test').map(item => item.id),
|
||
defer: options.filter(item => item.lane.id === 'defer').map(item => item.id),
|
||
park: options.filter(item => item.lane.id === 'park').map(item => item.id),
|
||
},
|
||
buildOrderDetails: {
|
||
doFirst: compactBuildItems(options.filter(item => item.lane.id === 'do')),
|
||
validateNext: compactBuildItems(options.filter(item => item.lane.id === 'test')),
|
||
defer: compactBuildItems(options.filter(item => item.lane.id === 'defer')),
|
||
park: compactBuildItems(options.filter(item => item.lane.id === 'park')),
|
||
},
|
||
});
|
||
});
|
||
|
||
app.get(/.*/, (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
|
||
|
||
app.use((error, _req, res, _next) => {
|
||
console.error('[rank]', error);
|
||
res.status(error.code && error.code >= 400 && error.code < 600 ? error.code : 500).json({ error: error.message || 'Internal error' });
|
||
});
|
||
|
||
app.listen(PORT, () => console.log(`[rank] ${appVersion} listening on :${PORT}`));
|