Accept messy idea dumps for feedback ranking
This commit is contained in:
@@ -228,6 +228,7 @@ async function createFeedbackMap(event) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const submit = form.querySelector('button[type="submit"]');
|
const submit = form.querySelector('button[type="submit"]');
|
||||||
const payload = Object.fromEntries(new FormData(form).entries());
|
const payload = Object.fromEntries(new FormData(form).entries());
|
||||||
|
if (!String(payload.optionsText || '').trim()) payload.optionsText = payload.idea;
|
||||||
submit.disabled = true;
|
submit.disabled = true;
|
||||||
submit.textContent = 'Judging…';
|
submit.textContent = 'Judging…';
|
||||||
try {
|
try {
|
||||||
|
|||||||
+6
-6
@@ -24,7 +24,7 @@
|
|||||||
<div class="hero-copy">
|
<div class="hero-copy">
|
||||||
<p class="eyebrow">Feedback intake for ideas, features, offers, and next moves</p>
|
<p class="eyebrow">Feedback intake for ideas, features, offers, and next moves</p>
|
||||||
<h1 id="hero-title">Submit the mess. Get a defended first move.</h1>
|
<h1 id="hero-title">Submit the mess. Get a defended first move.</h1>
|
||||||
<p class="lede">Ranker is the front door to useful feedback: paste an idea plus the features or possible moves around it, choose the judgement lens, and get a decision brief with reasons, risks, expert reflections, and next steps.</p>
|
<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>
|
||||||
<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">Rank a messy list</a>
|
||||||
<button class="button ghost" type="button" id="loadSampleTop">Load sample</button>
|
<button class="button ghost" type="button" id="loadSampleTop">Load sample</button>
|
||||||
@@ -59,18 +59,18 @@
|
|||||||
<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 · feedback front door · no account · no dashboard swamp</p>
|
||||||
<h2 id="try-title">Send a decision brief to the room</h2>
|
<h2 id="try-title">Send a decision brief to the room</h2>
|
||||||
<p>Describe what you want feedback on, then list the possible features, directions, offers, or next steps. Ranker gives you the quick judgement first, then the deeper reflections.</p>
|
<p>Paste the backlog, rough notes, feature dump, or possible next moves. Ranker will extract candidates when it can; use the optional candidate 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>What do you want feedback on?</span>
|
<span>Paste the messy backlog / idea dump <b>required</b></span>
|
||||||
<textarea name="idea" rows="4" placeholder="Example: I’m building a tool that helps freelancers package their services and decide what to sell first."></textarea>
|
<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>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span>Possible moves / features / functionality <b>required</b></span>
|
<span>Candidate moves, if you want to separate them <em>optional</em></span>
|
||||||
<textarea name="optionsText" rows="9" required placeholder="One per line. Bullets, rambling, half-thoughts are fine.
|
<textarea name="optionsText" rows="7" placeholder="Optional. One per line is easiest, but bullets and half-thoughts are fine.
|
||||||
- Offer critique
|
- Offer critique
|
||||||
- Pricing calculator
|
- Pricing calculator
|
||||||
- Proposal generator
|
- Proposal generator
|
||||||
|
|||||||
@@ -97,6 +97,25 @@ try {
|
|||||||
assert.ok(data.brief.whatWouldChangeRanking.some(item => /evidence fails|re-run the order/i.test(item)));
|
assert.ok(data.brief.whatWouldChangeRanking.some(item => /evidence fails|re-run the order/i.test(item)));
|
||||||
assert.ok(Array.isArray(data.brief.assumptions));
|
assert.ok(Array.isArray(data.brief.assumptions));
|
||||||
|
|
||||||
|
const messyIdeaOnlyResponse = await fetch(`${base}/api/rank-feedback`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
idea: '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.',
|
||||||
|
context: 'Solo builder. Manual proof first. Avoid dashboards, accounts, and saved workspaces before evidence.',
|
||||||
|
mode: 'validation',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(messyIdeaOnlyResponse.status, 200);
|
||||||
|
const messyIdeaOnly = await messyIdeaOnlyResponse.json();
|
||||||
|
assert.equal(messyIdeaOnly.input.optionCount, 6, 'idea-only messy dumps should be split into rank-ready candidates');
|
||||||
|
assert.ok(messyIdeaOnly.ranked.some(item => /Offer critique/i.test(item.title)));
|
||||||
|
assert.ok(messyIdeaOnly.ranked.some(item => /Pricing calculator/i.test(item.title)));
|
||||||
|
assert.equal(messyIdeaOnly.ranked.find(item => /dashboard/i.test(item.title)).lane.source, 'source-non-goal');
|
||||||
|
assert.ok(!/dashboard/i.test(messyIdeaOnly.ranked[0].title), 'dashboard-flavored candidate must not win tired-user first pass');
|
||||||
|
assert.ok(!messyIdeaOnly.handoff.warnings.some(item => /missing source section|missing original prompt/.test(item)));
|
||||||
|
assert.ok(messyIdeaOnly.handoff.warnings.includes('missing source artifact id'));
|
||||||
|
|
||||||
const hintedResponse = await fetch(`${base}/api/rank-feedback`, {
|
const hintedResponse = await fetch(`${base}/api/rank-feedback`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -428,18 +428,40 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseOptionsFromText(value) {
|
function parseOptionsFromText(value, sourceSection = 'optionsText') {
|
||||||
const text = cleanMultiline(value, 12000);
|
const text = cleanMultiline(value, 12000);
|
||||||
const lines = text.split('\n').map(line => line.replace(/^\s*[-*•\d.)]+\s*/, '').trim()).filter(Boolean);
|
const candidateText = /\b(maybe|possibly|options?|features?|ideas?|next moves?|functionality|backlog)\b[:\s]/i.test(text)
|
||||||
const optionLines = lines.length >= 2 ? lines : text.split(/[;|]/).map(part => part.trim()).filter(Boolean);
|
? 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) => {
|
return optionLines.slice(0, 24).map((line, index) => {
|
||||||
const [rawTitle, ...rest] = line.split(/\s*[-–—:]\s+/);
|
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 {
|
return {
|
||||||
id: `option-${index + 1}`,
|
id: `option-${index + 1}`,
|
||||||
title: cleanText(rawTitle || line, 140),
|
title: cleanText(rawTitle || normalized || line, 140),
|
||||||
description: cleanText(rest.join(' — '), 420),
|
description: cleanText(rest.join(' — '), 420),
|
||||||
|
provenance: { sourceSection },
|
||||||
};
|
};
|
||||||
}).filter(item => item.title);
|
}).filter(item => item.title && !/^(i('| a)?m|i am|we are|i only|want|need)\b/i.test(item.title));
|
||||||
}
|
}
|
||||||
|
|
||||||
function objectFrom(value) {
|
function objectFrom(value) {
|
||||||
@@ -458,7 +480,7 @@ function cleanProvenance(input = {}) {
|
|||||||
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),
|
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),
|
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),
|
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 || '', 1200),
|
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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,7 +582,11 @@ function nonGoalConflicts(optionText, decisionContext = {}) {
|
|||||||
const lower = String(optionText || '').toLowerCase();
|
const lower = String(optionText || '').toLowerCase();
|
||||||
return (decisionContext.nonGoals || []).filter(nonGoal => {
|
return (decisionContext.nonGoals || []).filter(nonGoal => {
|
||||||
const tokens = meaningfulTokens(nonGoal);
|
const tokens = meaningfulTokens(nonGoal);
|
||||||
return tokens.length > 0 && tokens.some(token => lower.includes(token));
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,7 +730,15 @@ function optionsFromBody(body = {}) {
|
|||||||
if (Array.isArray(body.options)) {
|
if (Array.isArray(body.options)) {
|
||||||
return normalizeOptionIds(body.options.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, 'option', 'options')).filter(item => item.title));
|
return normalizeOptionIds(body.options.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, 'option', 'options')).filter(item => item.title));
|
||||||
}
|
}
|
||||||
return normalizeOptionIds(parseOptionsFromText(body.optionsText || featureSet.optionsText || conceptMap.optionsText || ''));
|
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 = {}) {
|
function scoreOption(option, mode, context = '', decisionContext = {}) {
|
||||||
|
|||||||
Reference in New Issue
Block a user