feat: add AI idea placement triage
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
const PLACEMENTS = ['now', 'next', 'later', 'parking_lot', 'rejected', 'duplicate', 'needs_clarification']
|
||||
const RISKS = ['low', 'medium', 'high', 'dangerous']
|
||||
|
||||
const systemInstruction = `You are BuildPulse Scope Guardian.
|
||||
Your job is to classify new ideas for a solo AI-assisted builder.
|
||||
You must protect the current project scope.
|
||||
You are not trying to make the project bigger.
|
||||
You are trying to decide where the idea safely belongs.
|
||||
Classify the idea into one of:
|
||||
- now
|
||||
- next
|
||||
- later
|
||||
- parking_lot
|
||||
- rejected
|
||||
- duplicate
|
||||
- needs_clarification
|
||||
Rules:
|
||||
- If the idea is required for the current goal, suggest now.
|
||||
- If useful soon but not required now, suggest next.
|
||||
- If valid but not soon, suggest later.
|
||||
- If exciting but distracting, risky, or too large, suggest parking_lot.
|
||||
- If not useful, unsafe, or contrary to the product goal, suggest rejected.
|
||||
- If it overlaps existing features or parked ideas, suggest duplicate.
|
||||
- If there is not enough information, suggest needs_clarification.
|
||||
Always provide:
|
||||
- suggested_placement
|
||||
- scope_risk
|
||||
- confidence_score
|
||||
- reason
|
||||
- smallest_safe_version
|
||||
- suggested_title
|
||||
- suggested_description
|
||||
If suggesting a feature, include acceptance criteria.
|
||||
If suggesting parking_lot, include parking reason.
|
||||
If duplicate, identify likely duplicate.
|
||||
If needs_clarification, ask one clear question.
|
||||
Return strict JSON only.
|
||||
Do not include markdown.
|
||||
Do not include prose outside JSON.`
|
||||
|
||||
const responseShape = {
|
||||
suggested_placement: 'parking_lot',
|
||||
scope_risk: 'high',
|
||||
confidence_score: 0.82,
|
||||
reason: 'This requires real agent integration, which is out of scope for v0.2.',
|
||||
smallest_safe_version: 'Add a manual source/agent dropdown to Pulse events.',
|
||||
suggested_title: 'OpenClaw live agent tracking',
|
||||
suggested_description: 'Display live agent status from OpenClaw inside BuildPulse.',
|
||||
suggested_acceptance_criteria: [],
|
||||
suggested_parking_reason: 'Belongs to Agent Pulse v1.0+.',
|
||||
duplicate_check: {
|
||||
is_duplicate: true,
|
||||
duplicate_type: 'parking_lot',
|
||||
duplicate_id: 'parked_real_agent_pulse',
|
||||
reason: 'Real agent ingestion already exists in Parking Lot.',
|
||||
},
|
||||
clarifying_question: null,
|
||||
}
|
||||
|
||||
const cleanJsonText = (text) =>
|
||||
String(text || '')
|
||||
.trim()
|
||||
.replace(/^```json\s*/i, '')
|
||||
.replace(/^```\s*/i, '')
|
||||
.replace(/```$/i, '')
|
||||
.trim()
|
||||
|
||||
const asString = (value, fallback = '') => (typeof value === 'string' ? value.trim() : fallback)
|
||||
const clampConfidence = (value) => {
|
||||
const number = Number(value)
|
||||
if (!Number.isFinite(number)) return 0.5
|
||||
return Math.max(0, Math.min(1, number))
|
||||
}
|
||||
const stringArray = (value) => (Array.isArray(value) ? value.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean) : [])
|
||||
|
||||
export const validateRecommendation = (raw) => {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
throw new Error('AI response was not a JSON object.')
|
||||
}
|
||||
|
||||
const suggestedPlacement = asString(raw.suggested_placement)
|
||||
const scopeRisk = asString(raw.scope_risk)
|
||||
|
||||
if (!PLACEMENTS.includes(suggestedPlacement)) {
|
||||
throw new Error(`AI response used invalid placement: ${suggestedPlacement || 'missing'}`)
|
||||
}
|
||||
if (!RISKS.includes(scopeRisk)) {
|
||||
throw new Error(`AI response used invalid scope risk: ${scopeRisk || 'missing'}`)
|
||||
}
|
||||
|
||||
const duplicate = raw.duplicate_check && typeof raw.duplicate_check === 'object' && !Array.isArray(raw.duplicate_check) ? raw.duplicate_check : {}
|
||||
|
||||
return {
|
||||
suggested_placement: suggestedPlacement,
|
||||
scope_risk: scopeRisk,
|
||||
confidence_score: clampConfidence(raw.confidence_score),
|
||||
reason: asString(raw.reason, 'No reason provided.'),
|
||||
smallest_safe_version: asString(raw.smallest_safe_version, 'Define the smallest manual version before building.'),
|
||||
suggested_title: asString(raw.suggested_title, 'Untitled idea'),
|
||||
suggested_description: asString(raw.suggested_description, ''),
|
||||
suggested_acceptance_criteria: stringArray(raw.suggested_acceptance_criteria),
|
||||
suggested_parking_reason: asString(raw.suggested_parking_reason, ''),
|
||||
duplicate_check: {
|
||||
is_duplicate: Boolean(duplicate.is_duplicate),
|
||||
duplicate_type: ['feature', 'parking_lot'].includes(duplicate.duplicate_type) ? duplicate.duplicate_type : null,
|
||||
duplicate_id: asString(duplicate.duplicate_id) || null,
|
||||
reason: asString(duplicate.reason) || null,
|
||||
},
|
||||
clarifying_question: asString(raw.clarifying_question) || null,
|
||||
}
|
||||
}
|
||||
|
||||
export const buildTriagePrompt = ({ rawIdea, optionalContext, appContext }) =>
|
||||
[
|
||||
systemInstruction,
|
||||
'',
|
||||
'Current compact BuildPulse context:',
|
||||
JSON.stringify(appContext, null, 2),
|
||||
'',
|
||||
'Raw idea:',
|
||||
rawIdea,
|
||||
optionalContext ? `\nOptional user context:\n${optionalContext}` : '',
|
||||
'',
|
||||
'Return JSON matching this shape exactly. Use only allowed placement and risk values:',
|
||||
JSON.stringify(responseShape, null, 2),
|
||||
].join('\n')
|
||||
|
||||
const callGemini = async ({ prompt }) => {
|
||||
const apiKey = process.env.BUILDPULSE_AI_API_KEY || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
|
||||
if (!apiKey) {
|
||||
throw new Error('No Gemini API key configured. Set GEMINI_API_KEY or BUILDPULSE_AI_API_KEY on the server.')
|
||||
}
|
||||
|
||||
const model = process.env.BUILDPULSE_AI_MODEL || 'gemini-2.5-flash'
|
||||
const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), Number(process.env.BUILDPULSE_AI_TIMEOUT_MS || 20000))
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-goog-api-key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: prompt }],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
temperature: 0.2,
|
||||
responseMimeType: 'application/json',
|
||||
},
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || `Gemini request failed with ${response.status}`)
|
||||
}
|
||||
|
||||
const text = payload?.candidates?.[0]?.content?.parts?.map((part) => part.text || '').join('')
|
||||
if (!text) throw new Error('Gemini returned no text.')
|
||||
|
||||
return JSON.parse(cleanJsonText(text))
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export const triageIdea = async ({ rawIdea, optionalContext, appContext }) => {
|
||||
const idea = asString(rawIdea)
|
||||
if (!idea) throw new Error('raw_idea is required.')
|
||||
|
||||
const prompt = buildTriagePrompt({ rawIdea: idea, optionalContext: asString(optionalContext), appContext: appContext || {} })
|
||||
const provider = process.env.BUILDPULSE_AI_PROVIDER || 'gemini'
|
||||
|
||||
if (provider !== 'gemini') {
|
||||
throw new Error(`Unsupported BuildPulse AI provider: ${provider}. v0.2 ships with gemini only.`)
|
||||
}
|
||||
|
||||
const rawRecommendation = await callGemini({ prompt })
|
||||
return validateRecommendation(rawRecommendation)
|
||||
}
|
||||
Reference in New Issue
Block a user