Refocus Ranker as Prioritix idea-layer sorting
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "npm run check",
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"check": "node --check server.js && node --check scripts/setup-appwrite.mjs && node --check scripts/check-rank-feedback.mjs && node --check public/app.js && node scripts/check-rank-feedback.mjs",
|
"check": "node --check server.js && node --check scripts/setup-appwrite.mjs && node --check scripts/check-rank-feedback.mjs && node --check public/app.js && node scripts/check-rank-feedback.mjs",
|
||||||
"setup:appwrite": "node scripts/setup-appwrite.mjs",
|
"setup:appwrite": "node scripts/setup-appwrite.mjs",
|
||||||
|
|||||||
+15
-10
@@ -1,14 +1,19 @@
|
|||||||
const sample = {
|
const sample = {
|
||||||
idea: 'Scattermind clarified a messy course idea. Now I need feedback on the feature/functionality order, not a dashboard.',
|
idea: 'Scattermind clarified a messy freelancer-offer idea. Now I need Prioritix to sort the decision layer: some items are features, some are validation questions, and some are feedback themes.',
|
||||||
optionsText: `- Manual build-order preview from one Concept Map
|
optionsText: `Validation questions:
|
||||||
- Copyable decision brief with Do first / Validate next / Defer / Park
|
- Will freelancers pay for critique before software?
|
||||||
- Evidence questions beside each next move
|
- Which offer type hurts enough to buy help?
|
||||||
- Accounts and saved workspaces
|
Feedback themes:
|
||||||
- Team voting on roadmap priority
|
- They want templates
|
||||||
- Subscription billing layer
|
- They distrust generic AI copy
|
||||||
- Polished export for sharing the defended order`,
|
Features:
|
||||||
context: 'Snapshot / Concept Map handoff, solo builder, tired non-AI-native user, avoid auth/workspaces/billing before proof.',
|
- Manual offer critique
|
||||||
mode: 'mvp',
|
- Pricing calculator
|
||||||
|
- Proposal generator
|
||||||
|
- Public mini-page for one offer
|
||||||
|
- Client dashboard`,
|
||||||
|
context: 'Solo builder, one weekend, no auth/workspaces/billing before proof. Either validation or features can be useful for the first run.',
|
||||||
|
mode: 'validation',
|
||||||
};
|
};
|
||||||
|
|
||||||
const laneMeta = {
|
const laneMeta = {
|
||||||
|
|||||||
+24
-21
@@ -4,9 +4,9 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#f3eee4" />
|
<meta name="theme-color" content="#f3eee4" />
|
||||||
<meta name="rank-version" content="2.2.3-proof-script" />
|
<meta name="rank-version" content="2.3.0-prioritix" />
|
||||||
<title>Ranker — feedback front door for messy decisions</title>
|
<title>Ranker / Prioritix — sort the messy layer inside an idea</title>
|
||||||
<link rel="stylesheet" href="/styles.css?v=2.2.3-proof-script" />
|
<link rel="stylesheet" href="/styles.css?v=2.3.0-prioritix" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="page-shell">
|
<main class="page-shell">
|
||||||
@@ -22,11 +22,11 @@
|
|||||||
|
|
||||||
<div class="hero-grid" id="top">
|
<div class="hero-grid" id="top">
|
||||||
<div class="hero-copy">
|
<div class="hero-copy">
|
||||||
<p class="eyebrow">Feedback intake for ideas, features, offers, and next moves</p>
|
<p class="eyebrow">Prioritix for ideas: features, validation, feedback, risks, audiences, and next moves</p>
|
||||||
<h1 id="hero-title">Submit the mess. Get a defended first move.</h1>
|
<h1 id="hero-title">Sort the messy layer inside an idea.</h1>
|
||||||
<p class="lede">Ranker is the front door to useful feedback: paste the messy backlog as-is, choose the judgement lens, and get a decision brief with reasons, risks, expert reflections, and next steps.</p>
|
<p class="lede">Ranker / Prioritix takes the piece of an idea that needs ordering — features, functionality, validation questions, feedback themes, risks, experiments, or audiences — and returns a defended next-step map.</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a class="button primary" href="#try">Rank a messy list</a>
|
<a class="button primary" href="#try">Sort an idea layer</a>
|
||||||
<button class="button ghost" type="button" id="loadSampleTop">Load sample</button>
|
<button class="button ghost" type="button" id="loadSampleTop">Load sample</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="promise-row" aria-label="What Ranker returns">
|
<div class="promise-row" aria-label="What Ranker returns">
|
||||||
@@ -57,28 +57,31 @@
|
|||||||
|
|
||||||
<section class="decision-tool" id="try" aria-labelledby="try-title">
|
<section class="decision-tool" id="try" aria-labelledby="try-title">
|
||||||
<div class="tool-intro">
|
<div class="tool-intro">
|
||||||
<p class="eyebrow">MVP · feedback front door · no account · no dashboard swamp</p>
|
<p class="eyebrow">MVP · idea-layer sorting · no account · no dashboard swamp</p>
|
||||||
<h2 id="try-title">Send a decision brief to the room</h2>
|
<h2 id="try-title">Send the messy idea layer to Prioritix</h2>
|
||||||
<p>Paste the backlog, rough notes, feature dump, possible next moves, or a raw Scattermind Concept Map JSON export. Ranker will extract candidates when it can; use the optional candidate box only if you already have a cleaner list.</p>
|
<p>Paste the rough idea plus the layer you need sorted: features, functions, validation questions, feedback themes, risks, audiences, experiments, possible next moves, or a raw Scattermind Concept Map JSON export. Ranker will extract sortable items when it can; use the optional box only if you already have a cleaner list.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="rank-form" id="rankForm">
|
<form class="rank-form" id="rankForm">
|
||||||
<label>
|
<label>
|
||||||
<span>Paste the messy backlog / idea dump / Concept Map JSON <b>required</b></span>
|
<span>Paste the idea + messy layer / Concept Map JSON <b>required</b></span>
|
||||||
<textarea name="idea" rows="8" required placeholder="Example: I’m building a tool that helps freelancers package their services and decide what to sell first. Maybe offer critique, pricing calculator, proposal generator, landing page copywriter, client persona mapper, and some kind of dashboard later? I only have a week and want the fastest useful proof.
|
<textarea name="idea" rows="8" required placeholder="Example: I’m building a tool that helps freelancers package their services and decide what to sell first. Maybe offer critique, pricing calculator, proposal generator, landing page copywriter, client persona mapper, and some kind of dashboard later? I only have a week and want the fastest useful proof.
|
||||||
|
|
||||||
Or paste a Scattermind Concept Map JSON object here; Ranker will preserve source provenance and extract the build order."></textarea>
|
Or paste a Scattermind Concept Map JSON object here; Ranker will preserve source provenance and extract the build order."></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span>Candidate moves, if you want to separate them <em>optional</em></span>
|
<span>Sortable items, if you want to separate them <em>optional</em></span>
|
||||||
<textarea name="optionsText" rows="7" placeholder="Optional. One per line is easiest, but bullets and half-thoughts are fine.
|
<textarea name="optionsText" rows="7" placeholder="Optional. One per line is easiest, but bullets and half-thoughts are fine.
|
||||||
|
Validation questions:
|
||||||
|
- Will freelancers pay for critique before software?
|
||||||
|
- Which offer type hurts enough to buy help?
|
||||||
|
Feedback themes:
|
||||||
|
- They want templates
|
||||||
|
- They distrust generic AI copy
|
||||||
|
Features:
|
||||||
- Offer critique
|
- Offer critique
|
||||||
- Pricing calculator
|
- Pricing calculator"></textarea>
|
||||||
- Proposal generator
|
|
||||||
- Client persona mapper
|
|
||||||
- Landing page copywriter
|
|
||||||
- Client dashboard"></textarea>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<fieldset class="mode-picker">
|
<fieldset class="mode-picker">
|
||||||
@@ -98,7 +101,7 @@ Or paste a Scattermind Concept Map JSON object here; Ranker will preserve source
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="button primary" type="submit">Create ranked feedback map</button>
|
<button class="button primary" type="submit">Create Prioritix map</button>
|
||||||
<button class="button ghost" type="button" id="loadSample">Use sample</button>
|
<button class="button ghost" type="button" id="loadSample">Use sample</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -121,12 +124,12 @@ Or paste a Scattermind Concept Map JSON object here; Ranker will preserve source
|
|||||||
|
|
||||||
<section class="why-section" id="why" aria-labelledby="why-title">
|
<section class="why-section" id="why" aria-labelledby="why-title">
|
||||||
<p class="eyebrow">Positioning</p>
|
<p class="eyebrow">Positioning</p>
|
||||||
<h2 id="why-title">Scattermind clarifies one idea. Ranker judges the possible moves.</h2>
|
<h2 id="why-title">Scattermind evaluates one idea. Ranker / Prioritix sorts the layer that needs order.</h2>
|
||||||
<p>That makes Ranker broader: scattered people get relief, structured people get a second opinion, and builders get a defensible build order before they waste time on the wrong piece.</p>
|
<p>Sometimes that layer is features. Sometimes it is validation questions, feedback, risks, audiences, claims, experiments, or next moves. The point is not “features first”; the point is a defensible order before more development.</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div class="toast" id="toast" hidden></div>
|
<div class="toast" id="toast" hidden></div>
|
||||||
<script src="/app.js?v=2.2.3-proof-script" type="module"></script>
|
<script src="/app.js?v=2.3.0-prioritix" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -159,6 +159,42 @@ try {
|
|||||||
assert.equal(messyIdeaOnly.handoff.readiness.status, 'usable-with-warnings');
|
assert.equal(messyIdeaOnly.handoff.readiness.status, 'usable-with-warnings');
|
||||||
assert.ok(messyIdeaOnly.handoff.readiness.nextChecks.some(item => /source artifact id if this came from Scattermind/i.test(item)));
|
assert.ok(messyIdeaOnly.handoff.readiness.nextChecks.some(item => /source artifact id if this came from Scattermind/i.test(item)));
|
||||||
|
|
||||||
|
const prioritixLayerResponse = await fetch(`${base}/api/rank-feedback`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
idea: 'A simple product for freelancers to package tiny fixed-price offers.',
|
||||||
|
optionsText: `Idea: A simple product for freelancers to package tiny fixed-price offers.
|
||||||
|
Validation questions:
|
||||||
|
A) Will freelancers pay for critique before software?
|
||||||
|
B) Which offer type hurts enough to buy help?
|
||||||
|
Feedback themes:
|
||||||
|
- They want templates
|
||||||
|
- They distrust generic AI copy
|
||||||
|
Features:
|
||||||
|
1. Offer builder with 5 questions
|
||||||
|
2. Public mini-page for one offer
|
||||||
|
3. Stripe payment link helper
|
||||||
|
Constraints:
|
||||||
|
I only have one weekend and no audience.
|
||||||
|
Instruction:
|
||||||
|
Sort the idea layer, not my constraints.`,
|
||||||
|
context: 'Solo builder. Either validation questions, feedback, or features may be useful for the first Prioritix run.',
|
||||||
|
mode: 'validation',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(prioritixLayerResponse.status, 200);
|
||||||
|
const prioritixLayer = await prioritixLayerResponse.json();
|
||||||
|
const prioritixTitles = prioritixLayer.ranked.map(item => item.title);
|
||||||
|
assert.equal(prioritixLayer.input.optionCount, 7, 'Prioritix should extract mixed idea-layer items and ignore context/instruction sections');
|
||||||
|
assert.ok(prioritixTitles.some(title => /Will freelancers pay for critique before software/i.test(title)));
|
||||||
|
assert.ok(prioritixTitles.some(title => /Which offer type hurts enough to buy help/i.test(title)));
|
||||||
|
assert.ok(prioritixTitles.some(title => /They want templates/i.test(title)));
|
||||||
|
assert.ok(prioritixTitles.some(title => /Offer builder with 5 questions/i.test(title)));
|
||||||
|
assert.ok(!prioritixTitles.some(title => /I only have one weekend|Sort the idea layer|Instruction|Constraints/i.test(title)), 'constraints/instructions must not become ranked items');
|
||||||
|
assert.ok(!prioritixTitles.includes('A'));
|
||||||
|
assert.ok(!prioritixTitles.includes('B'));
|
||||||
|
|
||||||
const hardRailResponse = await fetch(`${base}/api/rank-feedback`, {
|
const hardRailResponse = await fetch(`${base}/api/rank-feedback`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -448,19 +448,70 @@ function hits(text, words) {
|
|||||||
return words.reduce((count, word) => count + (lower.includes(word) ? 1 : 0), 0);
|
return words.reduce((count, word) => count + (lower.includes(word) ? 1 : 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sortableTextHeadings = /^(features?|functionality|functions?|options?|variants?|claims?|promises?|risks?|assumptions?|experiments?|tests?|proof steps?|validation questions?|questions?|feedback themes?|feedback|audiences?|segments?|use cases?|possible next moves?|next moves?|moves?|candidates?|things to sort|sortable layer|xyz)\s*:\s*$/i;
|
||||||
|
const contextTextHeadings = /^(idea|context|constraints?|instructions?|instruction|notes?|non[- ]?goals?|avoid|do not sort|don't sort|dont sort|background|goal|goals?)\s*:\s*$/i;
|
||||||
|
const inlineSortablePrefix = /^(features?|functionality|functions?|options?|variants?|claims?|promises?|risks?|assumptions?|experiments?|tests?|proof steps?|validation questions?|questions?|feedback themes?|feedback|audiences?|segments?|use cases?|possible next moves?|next moves?|moves?|candidates?|things to sort|sortable layer|xyz)\s*:\s*/i;
|
||||||
|
|
||||||
|
function normalizeSortableTextLine(line = '') {
|
||||||
|
return cleanText(line, 900)
|
||||||
|
.replace(/^\s*(?:[-*•]|\d+[.)]|[a-z][.)])\s*/i, '')
|
||||||
|
.replace(/^\s*(?:rough\s+idea|idea|option|feature|function|variant|claim|risk|experiment|test|question|feedback|audience|segment|move)\s+[a-z0-9]+\s*[:.)-]\s*/i, '')
|
||||||
|
.replace(/^\s*(maybe|possibly|also|plus|and|or|then|later|eventually|build|add|include|some kind of|kind of)\b\s*/i, '')
|
||||||
|
.replace(/\?+$/i, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitOptionTitleDescription(line = '') {
|
||||||
|
const normalized = normalizeSortableTextLine(line);
|
||||||
|
const [rawTitle, ...rest] = normalized.split(/\s[-–—]\s/);
|
||||||
|
return {
|
||||||
|
title: cleanText(rawTitle || normalized || line, 140),
|
||||||
|
description: cleanText(rest.join(' — '), 420),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function parseOptionsFromText(value, sourceSection = 'optionsText') {
|
function parseOptionsFromText(value, sourceSection = 'optionsText') {
|
||||||
const text = cleanMultiline(value, 12000);
|
const text = cleanMultiline(value, 12000);
|
||||||
const candidateText = /\b(maybe|possibly|options?|features?|ideas?|next moves?|functionality|backlog)\b[:\s]/i.test(text)
|
const lines = text.split('\n').map(line => line.trim()).filter(Boolean);
|
||||||
? text.replace(/^[\s\S]*?\b(maybe|possibly|options?|features?|ideas?|next moves?|functionality|backlog)\b[:\s]*/i, '')
|
const hasExplicitSortableHeading = lines.some(line => sortableTextHeadings.test(line));
|
||||||
|
const extracted = [];
|
||||||
|
let activeSection = hasExplicitSortableHeading ? 'context' : 'sortable';
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (sortableTextHeadings.test(line)) {
|
||||||
|
activeSection = 'sortable';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (contextTextHeadings.test(line)) {
|
||||||
|
activeSection = 'context';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const inlineSortable = line.match(inlineSortablePrefix);
|
||||||
|
if (inlineSortable) {
|
||||||
|
activeSection = 'sortable';
|
||||||
|
const rest = line.replace(inlineSortablePrefix, '').trim();
|
||||||
|
if (rest) extracted.push(rest);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (activeSection !== 'sortable') continue;
|
||||||
|
if (/^(constraints?|instructions?|context|notes?|goal|goals?)\s*:/i.test(line)) continue;
|
||||||
|
extracted.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
const looseText = !hasExplicitSortableHeading && /\b(maybe|possibly|options?|features?|functionality|variants?|claims?|risks?|experiments?|validation questions?|feedback themes?|audiences?|next moves?)\b[:\s]/i.test(text)
|
||||||
|
? text.replace(/^[\s\S]*?\b(maybe|possibly|options?|features?|functionality|variants?|claims?|risks?|experiments?|validation questions?|feedback themes?|audiences?|next moves?)\b[:\s]*/i, '')
|
||||||
: text;
|
: text;
|
||||||
const cleanedLines = candidateText.split('\n').map(line => line.replace(/^\s*[-*•\d.)]+\s*/, '').trim()).filter(Boolean);
|
const candidateText = (hasExplicitSortableHeading ? extracted.join('\n').trim() : '') || looseText
|
||||||
|
.replace(/\b(maybe|possibly|options?|features?|functionality|variants?|claims?|risks?|experiments?|validation questions?|feedback themes?|audiences?|next moves?)\s*:/gi, '\n')
|
||||||
|
.trim();
|
||||||
|
const cleanedLines = candidateText.split('\n').map(normalizeSortableTextLine).filter(Boolean);
|
||||||
const sentenceList = candidateText
|
const sentenceList = candidateText
|
||||||
.replace(/\b(maybe|possibly|options?|features?|ideas?|next moves?|functionality|backlog)\s*:/gi, '\n')
|
|
||||||
.split(/\n|;|\|/)
|
.split(/\n|;|\|/)
|
||||||
.map(part => part.replace(/^\s*[-*•\d.)]+\s*/, '').trim())
|
.map(normalizeSortableTextLine)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const commaList = sentenceList.length === 1
|
const commaList = sentenceList.length === 1
|
||||||
? sentenceList[0].split(/,|\s+and\s+|[.!?]\s+/i).map(part => part.trim()).filter(Boolean)
|
? sentenceList[0].split(/,|\s+and\s+|[.!?]\s+/i).map(normalizeSortableTextLine).filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
const optionLines = cleanedLines.length >= 2
|
const optionLines = cleanedLines.length >= 2
|
||||||
? cleanedLines
|
? cleanedLines
|
||||||
@@ -468,20 +519,16 @@ function parseOptionsFromText(value, sourceSection = 'optionsText') {
|
|||||||
? sentenceList
|
? sentenceList
|
||||||
: commaList.length >= 2
|
: commaList.length >= 2
|
||||||
? commaList
|
? commaList
|
||||||
: candidateText.split(/[;|]/).map(part => part.trim()).filter(Boolean);
|
: candidateText.split(/[;|]/).map(normalizeSortableTextLine).filter(Boolean);
|
||||||
return optionLines.slice(0, 24).map((line, index) => {
|
return optionLines.slice(0, 24).map((line, index) => {
|
||||||
const normalized = line
|
const { title, description } = splitOptionTitleDescription(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 {
|
return {
|
||||||
id: `option-${index + 1}`,
|
id: `option-${index + 1}`,
|
||||||
title: cleanText(rawTitle || normalized || line, 140),
|
title,
|
||||||
description: cleanText(rest.join(' — '), 420),
|
description,
|
||||||
provenance: { sourceSection },
|
provenance: { sourceSection },
|
||||||
};
|
};
|
||||||
}).filter(item => item.title && !/^(i('| a)?m|i am|we are|i only|want|need)\b/i.test(item.title));
|
}).filter(item => item.title && !/^(i('| a)?m|i am|we are|i only|want|need|rank the|sort the|do not|don't|dont)\b/i.test(item.title));
|
||||||
}
|
}
|
||||||
|
|
||||||
function objectFrom(value) {
|
function objectFrom(value) {
|
||||||
|
|||||||
Reference in New Issue
Block a user