From a348acf6ef95ed27d8a83a03c859c5237693c00d Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Sun, 31 May 2026 22:51:08 +0200 Subject: [PATCH] Refocus Ranker as Prioritix idea-layer sorting --- package.json | 2 +- public/app.js | 25 ++++++----- public/index.html | 45 ++++++++++--------- scripts/check-rank-feedback.mjs | 36 +++++++++++++++ server.js | 77 ++++++++++++++++++++++++++------- 5 files changed, 138 insertions(+), 47 deletions(-) diff --git a/package.json b/package.json index ae8bd3c..601c3d8 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "npm run check", "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", "setup:appwrite": "node scripts/setup-appwrite.mjs", diff --git a/public/app.js b/public/app.js index fecbd04..c956a15 100644 --- a/public/app.js +++ b/public/app.js @@ -1,14 +1,19 @@ const sample = { - idea: 'Scattermind clarified a messy course idea. Now I need feedback on the feature/functionality order, not a dashboard.', - optionsText: `- Manual build-order preview from one Concept Map -- Copyable decision brief with Do first / Validate next / Defer / Park -- Evidence questions beside each next move -- Accounts and saved workspaces -- Team voting on roadmap priority -- Subscription billing layer -- Polished export for sharing the defended order`, - context: 'Snapshot / Concept Map handoff, solo builder, tired non-AI-native user, avoid auth/workspaces/billing before proof.', - mode: 'mvp', + 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: `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: +- Manual offer critique +- 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 = { diff --git a/public/index.html b/public/index.html index df272e2..1232158 100644 --- a/public/index.html +++ b/public/index.html @@ -4,9 +4,9 @@ - - Ranker — feedback front door for messy decisions - + + Ranker / Prioritix — sort the messy layer inside an idea +
@@ -22,11 +22,11 @@
-

Feedback intake for ideas, features, offers, and next moves

-

Submit the mess. Get a defended first move.

-

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.

+

Prioritix for ideas: features, validation, feedback, risks, audiences, and next moves

+

Sort the messy layer inside an idea.

+

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.

@@ -57,28 +57,31 @@
-

MVP · feedback front door · no account · no dashboard swamp

-

Send a decision brief to the room

-

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.

+

MVP · idea-layer sorting · no account · no dashboard swamp

+

Send the messy idea layer to Prioritix

+

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.

@@ -98,7 +101,7 @@ Or paste a Scattermind Concept Map JSON object here; Ranker will preserve source
- +
@@ -121,12 +124,12 @@ Or paste a Scattermind Concept Map JSON object here; Ranker will preserve source

Positioning

-

Scattermind clarifies one idea. Ranker judges the possible moves.

-

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.

+

Scattermind evaluates one idea. Ranker / Prioritix sorts the layer that needs order.

+

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.

- + diff --git a/scripts/check-rank-feedback.mjs b/scripts/check-rank-feedback.mjs index c37ab84..9ede2bb 100644 --- a/scripts/check-rank-feedback.mjs +++ b/scripts/check-rank-feedback.mjs @@ -159,6 +159,42 @@ try { 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))); + 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`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/server.js b/server.js index 890a711..657e43b 100644 --- a/server.js +++ b/server.js @@ -448,19 +448,70 @@ function hits(text, words) { 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') { 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, '') + const lines = text.split('\n').map(line => line.trim()).filter(Boolean); + 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; - 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 - .replace(/\b(maybe|possibly|options?|features?|ideas?|next moves?|functionality|backlog)\s*:/gi, '\n') .split(/\n|;|\|/) - .map(part => part.replace(/^\s*[-*•\d.)]+\s*/, '').trim()) + .map(normalizeSortableTextLine) .filter(Boolean); 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 ? cleanedLines @@ -468,20 +519,16 @@ function parseOptionsFromText(value, sourceSection = 'optionsText') { ? sentenceList : commaList.length >= 2 ? commaList - : candidateText.split(/[;|]/).map(part => part.trim()).filter(Boolean); + : candidateText.split(/[;|]/).map(normalizeSortableTextLine).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+/); + const { title, description } = splitOptionTitleDescription(line); return { id: `option-${index + 1}`, - title: cleanText(rawTitle || normalized || line, 140), - description: cleanText(rest.join(' — '), 420), + title, + description, 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) {