feat: add AI idea placement triage

This commit is contained in:
OpenClaw Bot
2026-05-09 20:53:15 +02:00
parent 5909337f64
commit cfb6b06a08
9 changed files with 859 additions and 29 deletions
+188
View File
@@ -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)
}