Files
rank/server.js
T
2026-05-26 22:31:02 +02:00

617 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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: 'Risk reduction',
weights: { value: 0.95, feasibility: 1.0, confidence: 1.45, urgency: 0.8, revenue: 0.45, novelty: 0.25, risk: -1.85 },
next: 'Start where uncertainty is highest and evidence is cheapest. Do not build around an untested assumption.',
},
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) {
const text = cleanMultiline(value, 12000);
const lines = text.split('\n').map(line => line.replace(/^\s*[-*•\d.)]+\s*/, '').trim()).filter(Boolean);
const optionLines = lines.length >= 2 ? lines : text.split(/[;|]/).map(part => part.trim()).filter(Boolean);
return optionLines.slice(0, 24).map((line, index) => {
const [rawTitle, ...rest] = line.split(/\s[-–—:]\s/);
return {
id: `option-${index + 1}`,
title: cleanText(rawTitle || line, 140),
description: cleanText(rest.join(' — ') || line, 420),
};
}).filter(item => item.title);
}
function cleanProvenance(input = {}) {
const artifact = input.artifact && typeof input.artifact === 'object' ? input.artifact : {};
const source = input.source && typeof input.source === 'object' ? input.source : {};
return {
schema: cleanText(input.schema || artifact.schema || input.type || 'prioritix-feature-set-v1', 80),
source: cleanText(input.sourceName || source.name || artifact.sourceName || 'Scattermind', 80),
artifactId: cleanText(input.artifactId || input.sourceArtifactId || artifact.id || source.artifactId || '', 120),
snapshotTitle: cleanText(input.snapshotTitle || artifact.snapshotTitle || input.ideaTitle || '', 160),
conceptMapId: cleanText(input.conceptMapId || artifact.conceptMapId || '', 120),
originalPrompt: cleanMultiline(input.originalPrompt || input.initialPrompt || artifact.originalPrompt || source.originalPrompt || '', 1200),
};
}
function normalizeFeatureOption(item, index, fallbackId = 'feature') {
const title = cleanText(item?.title || item?.name || item?.action || '', 140);
const proofSteps = cleanTextList(item?.proofSteps || item?.proof || item?.validationSteps, 5, 180);
const dependencies = cleanTextList(item?.dependencies || item?.blockedBy, 5, 120);
const evidenceNeeded = cleanText(item?.evidenceNeeded || item?.evidence || item?.test || '', 260);
const userValue = cleanText(item?.userValue || item?.value || item?.outcome || item?.why, 260);
const risk = cleanText(item?.risk || item?.assumption || item?.unknown || '', 220);
const sourceSection = cleanText(item?.sourceSection || item?.section || item?.lane || item?.origin || '', 80);
const recommendedLane = cleanText(item?.recommendedLane || item?.laneHint || item?.suggestedLane || '', 40).toLowerCase();
const descriptionParts = [
item?.description || item?.brief || '',
userValue && `User value: ${userValue}`,
evidenceNeeded && `Evidence needed: ${evidenceNeeded}`,
risk && `Risk: ${risk}`,
proofSteps.length && `Proof steps: ${proofSteps.join('; ')}`,
dependencies.length && `Dependencies: ${dependencies.join(', ')}`,
].filter(Boolean);
return {
id: cleanText(item?.id || item?.key || `${fallbackId}-${index + 1}`, 80) || `${fallbackId}-${index + 1}`,
title,
description: cleanText(descriptionParts.join(' '), 760),
factors: { userValue, evidenceNeeded, risk, proofSteps, dependencies, recommendedLane, metricHints: cleanMetricHints(item) },
provenance: {
sourceId: cleanText(item?.sourceId || item?.sourceArtifactId || item?.id || '', 120),
sourceSection,
},
};
}
function optionsFromBody(body = {}) {
const featureSet = body.featureSet && typeof body.featureSet === 'object' ? body.featureSet : {};
const rawFeatures = Array.isArray(body.features) ? body.features : Array.isArray(featureSet.features) ? featureSet.features : null;
if (rawFeatures) {
return rawFeatures.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index)).filter(item => item.title);
}
if (Array.isArray(body.options)) {
return body.options.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, 'option')).filter(item => item.title);
}
return parseOptionsFromText(body.optionsText || featureSet.optionsText || '');
}
function scoreOption(option, mode, context = '') {
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 laneBoost = /do|first|now|build/.test(laneHint) ? 0.55 : /validate|test|proof/.test(laneHint) ? 0.25 : /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 + 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),
};
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));
return { ...metrics, score };
}
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 are safety rails:
// if the source already marked something as defer/park, 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 (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 reasonFor(option) {
const m = option.metrics;
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 (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.feasibility >= 7.2 && m.value >= 6.2) return 'high enough value with low enough delivery drag to create fast signal';
if (m.revenue >= 6.4) return 'clearer buyer or money signal than the rest of the list';
if (m.risk >= 6.5) return 'interesting, but it carries assumption risk that should be tested before build';
if (m.novelty >= 6.7) return 'more differentiated than the safe options, but still needs proof';
return 'balanced tradeoff across value, effort, confidence, and timing';
}
function concernFor(option) {
const m = option.metrics;
if ((option.factors?.dependencies || []).length >= 3) return 'Too many prerequisites. Split the proof slice before treating this as build-ready.';
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 createDecisionBrief({ idea, context, mode, ranked, provenance }) {
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 it has ${reasonFor(top)}.` : 'The list needs at least two options before ranking becomes useful.';
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}.` : ''}`,
source: provenance ? {
schema: provenance.schema,
source: provenance.source,
artifactId: provenance.artifactId,
snapshotTitle: provenance.snapshotTitle,
conceptMapId: provenance.conceptMapId,
} : 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 35 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 310 options.', 'Choose what the ranking should care about.', 'Run the first-pass judgement.'],
caution: 'This is first-pass judgement, not an oracle. Change the criteria if the context changes.',
};
}
app.post('/api/rank-feedback', (req, res) => {
const idea = cleanMultiline(req.body?.idea || '', 3000);
const context = cleanMultiline(req.body?.context || '', 3000);
const modeId = cleanText(req.body?.mode || 'progress', 40);
const mode = judgementModes[modeId] || judgementModes.progress;
const provenance = cleanProvenance(req.body || {});
let options = optionsFromBody(req.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}`;
options = options.map(option => ({ ...option, metrics: scoreOption(option, mode, bridgeContext) }))
.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),
};
});
res.json({
ok: true,
mode: { id: modeId in judgementModes ? modeId : 'progress', label: mode.label },
input: { idea, context, optionCount: options.length, provenance },
ranked: options,
brief: createDecisionBrief({ idea, context, mode, ranked: options, provenance }),
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),
},
});
});
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}`));