189 lines
6.9 KiB
JavaScript
189 lines
6.9 KiB
JavaScript
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)
|
|
}
|