pdm-ai
Version:
PDM-AI - Transform customer feedback into structured product insights using the Jobs-to-be-Done (JTBD) methodology
475 lines (405 loc) • 17.1 kB
JavaScript
// src/utils/jtbd/providers/openai-provider.js
import https from 'https';
import config from '../../config.js';
import logger from '../../logger.js';
/**
* Generate a JTBD using OpenAI API
* @param {Array} scenarios - Array of scenario objects
* @returns {Promise<Object>} Generated JTBD object
*/
async function generateJTBD(scenarios) {
try {
logger.info("Using OpenAI API for JTBD generation...");
// Use LLM_API_KEY as specified in the PRD
const apiKey = config.llmApiKey || process.env.LLM_API_KEY;
if (!apiKey) {
throw new Error("No API key available. Set LLM_API_KEY in your .env file");
}
// Get model configuration
const model = config.model || 'gpt-4o';
const maxTokens = config.maxTokens || 1000;
const temperature = config.temperature || 0.7;
// Log language being used
logger.debug(`Using language: ${config.language}`);
// Create prompts
const systemPrompt = createSystemPrompt();
const userPrompt = createUserPrompt(scenarios);
// Call OpenAI API
const response = await makeOpenAIAPIRequest(
apiKey,
systemPrompt,
userPrompt,
model,
maxTokens,
temperature
);
// Extract JSON from response
return extractJTBDFromResponse(response, scenarios);
} catch (error) {
logger.error(`OpenAI API error: ${error.message}`);
return createFallbackJTBD(scenarios);
}
}
/**
* Generate an abstract JTBD from multiple lower-level JTBDs
* @param {Array} jtbds - Array of JTBD objects
* @returns {Promise<Object>} Generated abstract JTBD object
*/
async function generateAbstractJTBD(jtbds) {
try {
logger.info(`Using OpenAI API to generate abstract JTBD from ${jtbds.length} JTBDs...`);
const apiKey = config.llmApiKey || process.env.LLM_API_KEY;
if (!apiKey) {
throw new Error("No API key available. Set LLM_API_KEY in your .env file");
}
// Get model configuration
const model = config.model || 'gpt-4o';
const maxTokens = config.maxTokens || 1000;
const temperature = config.temperature || 0.7;
// Create prompts
const systemPrompt = createSystemPromptForAbstraction();
const userPrompt = createUserPromptForAbstraction(jtbds);
// Call OpenAI API
const response = await makeOpenAIAPIRequest(
apiKey,
systemPrompt,
userPrompt,
model,
maxTokens,
temperature
);
// Extract JSON from response
return extractJTBDFromResponse(response, null, jtbds);
} catch (error) {
logger.error(`OpenAI API error for abstract JTBD: ${error.message}`);
return createFallbackAbstractJTBD(jtbds);
}
}
/**
* Create system prompt for OpenAI
* @returns {string} System prompt
*/
function createSystemPrompt() {
return `You are an expert product manager skilled at extracting Jobs-to-be-Done (JTBDs) from user scenarios.
You can identify the underlying job that multiple user scenarios are trying to accomplish.
Always provide your output as a valid JSON object with the structure specified in the prompt.
Do not include ANY text outside of the JSON object.`;
}
/**
* Create system prompt for JTBD abstraction
* @returns {string} System prompt
*/
function createSystemPromptForAbstraction() {
return `You are an expert product manager skilled at identifying higher-level Jobs-to-be-Done (JTBDs) from more specific JTBDs.
You can identify the underlying job that connects multiple more specific JTBDs.
Always provide your output as a valid JSON object with the structure specified in the prompt.
Your abstract JTBD should be more general but still specific enough to be meaningful and actionable.
Do not include ANY text outside of the JSON object.`;
}
/**
* Create user prompt for OpenAI
* @param {Array} scenarios - Array of scenario objects
* @returns {string} User prompt
*/
function createUserPrompt(scenarios) {
// Check if language should be Japanese, using the getter for direct access
const isJapanese = config.language === 'ja';
logger.debug(`Creating prompt with language setting: ${config.language}, isJapanese: ${isJapanese}`);
// Format scenarios for display in the prompt
const scenariosText = scenarios.map((scenario, index) =>
`${index + 1}. ${scenario.statement}`
).join('\n');
// Determine which prompt template to use based on language
if (isJapanese) {
return `以下のユーザーシナリオを分析し、これらのシナリオの根底にある「Jobs-to-be-Done (JTBD)」を抽出してください。
JTBDは「[状況]のとき、[モチベーション]したい、そうすれば[期待される結果]できる」の形式で表現してください。
ユーザーシナリオ:
${scenariosText}
優先度(1〜10、10が最高)を割り当て、JTBDをより具体的に説明するために状況、モチベーション、結果の要素を個別に特定してください。
また、このJTBDをサポートするための重要な引用や証拠を提供してください。
以下の形式で有効なJSONオブジェクトとして回答してください:
{
"statement": "[状況]のとき、[モチベーション]したい、そうすれば[期待される結果]できる",
"situation": "[状況]",
"motivation": "[モチベーション]",
"outcome": "[期待される結果]",
"sourceQuotes": ["ユーザーシナリオからの証拠となる引用"]
}
重要: JSONオブジェクトのみを返し、JSON以外のテキストを含めないでください。`;
}
// Default English prompt
return `Analyze the following user scenarios and extract the underlying Job-to-be-Done (JTBD) that these scenarios are trying to accomplish.
Express the JTBD in the format "When [situation], I want to [motivation], so I can [expected outcome]".
User Scenarios:
${scenariosText}
Identify the situation, motivation, and outcome elements separately to make the JTBD more specific.
Also provide key quotes or evidence that support this JTBD.
FORMAT YOUR RESPONSE AS A VALID JSON OBJECT WITH THIS STRUCTURE:
{
"statement": "When [situation], I want to [motivation], so I can [expected outcome]",
"situation": "[situation]",
"motivation": "[motivation]",
"outcome": "[expected outcome]",
"sourceQuotes": ["supporting quotes from user scenarios"]
}
IMPORTANT: Respond with ONLY the JSON object. Do not include any text outside the JSON.`;
}
/**
* Create user prompt for JTBD abstraction
* @param {Array} jtbds - Array of JTBD objects
* @returns {string} User prompt
*/
function createUserPromptForAbstraction(jtbds) {
// Check if language should be Japanese
const isJapanese = config.language === 'ja';
// Format JTBDs for display in the prompt
const jtbdsText = jtbds.map((jtbd, index) =>
`${index + 1}. ${jtbd.statement}\n Situation: ${jtbd.situation}\n Motivation: ${jtbd.motivation}\n Outcome: ${jtbd.outcome}`
).join('\n\n');
if (isJapanese) {
return `以下のJTBDを分析し、これらのJTBDをまとめるより抽象的な「高次のJTBD」を作成してください。
この高次のJTBDは、より具体的な複数のJTBDに共通する根本的なジョブを表現する必要があります。
JTBDは「[状況]のとき、[モチベーション]したい、そうすれば[期待される結果]できる」の形式で表現してください。
JTBDs:
${jtbdsText}
優先度(1〜10、10が最高)を割り当て、JTBDをより具体的に説明するために状況、モチベーション、結果の要素を個別に特定してください。
また、このJTBDをサポートするための重要な引用や証拠を提供してください。
以下の形式で有効なJSONオブジェクトとして回答してください:
{
"statement": "[状況]のとき、[モチベーション]したい、そうすれば[期待される結果]できる",
"situation": "[状況]",
"motivation": "[モチベーション]",
"outcome": "[期待される結果]",
"sourceQuotes": ["元のJTBDからの証拠となる引用"]
}
重要: JSONオブジェクトのみを返し、JSON以外のテキストを含めないでください。`;
}
// Default English prompt
return `Analyze the following Jobs-to-be-Done (JTBDs) and create a higher-level, more abstract JTBD that encompasses all of them.
This abstract JTBD should represent the fundamental job that connects these more specific JTBDs.
Express the JTBD in the format "When [situation], I want to [motivation], so I can [expected outcome]".
JTBDs:
${jtbdsText}
Identify the situation, motivation, and outcome elements separately to make the JTBD more specific.
Also provide key quotes or evidence from the original JTBDs that support this abstract JTBD.
FORMAT YOUR RESPONSE AS A VALID JSON OBJECT WITH THIS STRUCTURE:
{
"statement": "When [situation], I want to [motivation], so I can [expected outcome]",
"situation": "[situation]",
"motivation": "[motivation]",
"outcome": "[expected outcome]",
"sourceQuotes": ["supporting quotes from original JTBDs"]
}
IMPORTANT: Respond with ONLY the JSON object. Do not include any text outside the JSON.`;
}
/**
* Extract JTBD from OpenAI API response
* @param {string} response - API response text
* @param {Array} scenarios - Original scenarios (optional)
* @param {Array} jtbds - Original JTBDs for abstraction (optional)
* @returns {Object|null} Parsed JTBD or null if parsing fails
*/
function extractJTBDFromResponse(response, scenarios = null, jtbds = null) {
if (!response) return scenarios ? createFallbackJTBD(scenarios) : createFallbackAbstractJTBD(jtbds);
try {
// Strategy 1: Try to find a JSON block with ```json markers
const jsonBlockMatch = response.match(/```json\s*([\s\S]*?)\s*```/);
if (jsonBlockMatch && jsonBlockMatch[1]) {
return JSON.parse(jsonBlockMatch[1].trim());
}
// Strategy 2: Try to find anything that looks like a JSON object
const jsonObjectMatch = response.match(/(\{[\s\S]*\})/);
if (jsonObjectMatch && jsonObjectMatch[1]) {
// Try to fix common JSON parsing issues
let jsonCandidate = jsonObjectMatch[1].trim();
// Replace single quotes with double quotes
jsonCandidate = jsonCandidate.replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2":');
// Replace single quotes in values with double quotes
jsonCandidate = jsonCandidate.replace(/: ?'([^']*)'/g, ': "$1"');
return JSON.parse(jsonCandidate);
}
return scenarios ? createFallbackJTBD(scenarios) : createFallbackAbstractJTBD(jtbds);
} catch (e) {
logger.error(`Failed to parse API response: ${e.message}. Using fallback JTBD.`);
return scenarios ? createFallbackJTBD(scenarios) : createFallbackAbstractJTBD(jtbds);
}
}
/**
* Create a fallback JTBD when API call or parsing fails
* @param {Array} scenarios - Original scenarios
* @returns {Object} Fallback JTBD
*/
function createFallbackJTBD(scenarios) {
// Extract common personas
const personas = scenarios.map(s => s.persona).filter(Boolean);
let commonPersona = "user";
if (personas.length > 0) {
// Find the most frequent persona
const personaCounts = {};
let maxCount = 0;
personas.forEach(persona => {
personaCounts[persona] = (personaCounts[persona] || 0) + 1;
if (personaCounts[persona] > maxCount) {
maxCount = personaCounts[persona];
commonPersona = persona;
}
});
}
// Extract actions and values
const actions = scenarios.map(s => s.action).filter(Boolean);
const values = scenarios.map(s => s.value).filter(Boolean);
// Create a fallback statement
let situation = "using a product or service";
let motivation = "efficiently accomplish my goals";
let outcome = "improve my overall experience";
// Use actual data if available
if (actions.length > 0) {
motivation = actions[0] || motivation;
}
if (values.length > 0) {
outcome = values[0] || outcome;
}
return {
statement: `When ${situation}, I want to ${motivation}, so I can ${outcome}`,
situation: situation,
motivation: motivation,
outcome: outcome,
sourceQuotes: scenarios.map(s => s.statement).slice(0, 3)
};
}
/**
* Create a fallback abstract JTBD when API call or parsing fails
* @param {Array} jtbds - Original JTBDs
* @returns {Object} Fallback abstract JTBD
*/
function createFallbackAbstractJTBD(jtbds) {
// Extract situations, motivations, and outcomes
const situations = jtbds.map(j => j.situation).filter(Boolean);
const motivations = jtbds.map(j => j.motivation).filter(Boolean);
const outcomes = jtbds.map(j => j.outcome).filter(Boolean);
// Create a fallback statement
let situation = "facing a common challenge";
let motivation = "find an effective solution";
let outcome = "achieve my objectives efficiently";
// Use actual data if available
if (situations.length > 0 && situations[0]) {
situation = situations[0];
}
if (motivations.length > 0 && motivations[0]) {
motivation = motivations[0];
}
if (outcomes.length > 0 && outcomes[0]) {
outcome = outcomes[0];
}
return {
statement: `When ${situation}, I want to ${motivation}, so I can ${outcome}`,
situation: situation,
motivation: motivation,
outcome: outcome,
sourceQuotes: jtbds.map(j => j.statement).slice(0, 3)
};
}
/**
* Make a request to the OpenAI API
* @param {string} apiKey - OpenAI API key
* @param {string} systemMessage - System message
* @param {string} userMessage - User message
* @param {string} model - OpenAI model name
* @param {number} maxTokens - Maximum tokens to generate
* @param {number} temperature - Temperature setting
* @returns {Promise<string>} OpenAI API response
*/
function makeOpenAIAPIRequest(apiKey, systemMessage, userMessage, model, maxTokens, temperature) {
return new Promise((resolve, reject) => {
// Set a 3-minute timeout for the API call
const TIMEOUT_MS = 180000;
const requestData = {
model: model,
messages: [
{
role: "system",
content: systemMessage
},
{
role: "user",
content: userMessage
}
]
};
// Check if the model is a reasoning model
const reasoningModels = ['o4-mini', 'o3', 'o3-mini', 'o1', 'o1-mini', 'o1-pro'];
const isReasoningModel = reasoningModels.some(rm => model.includes(rm));
// Handle model-specific parameters
if (model.startsWith('o3') || model.startsWith('o4') || model.startsWith('o1')) {
// o1, o3 and o4 models use max_completion_tokens instead of max_tokens
requestData.max_completion_tokens = maxTokens;
// Only add temperature for non-reasoning models
if (!isReasoningModel) {
requestData.temperature = temperature;
} else {
// Reasoning models don't support temperature parameter
// https://platform.openai.com/docs/guides/reasoning
logger.debug(`Using reasoning model ${model}, skipping temperature parameter`);
}
} else {
// Other models use max_tokens and support custom temperature
requestData.max_tokens = maxTokens;
requestData.temperature = temperature;
}
const options = {
hostname: 'api.openai.com',
port: 443,
path: '/v1/chat/completions',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
timeout: TIMEOUT_MS
};
let timer = setTimeout(() => {
req.destroy();
reject(new Error(`Request timed out after ${TIMEOUT_MS/1000} seconds`));
}, TIMEOUT_MS);
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
clearTimeout(timer);
try {
if (res.statusCode !== 200) {
reject(new Error(`API returned status code ${res.statusCode}: ${data}`));
return;
}
const response = JSON.parse(data);
const textContent = response.choices &&
response.choices[0] &&
response.choices[0].message &&
response.choices[0].message.content;
// Return the text content or empty string
resolve(textContent || "");
} catch (e) {
logger.error(`Failed to parse API response: ${e.message}. Using empty string.`);
resolve("");
}
});
});
req.on('error', (e) => {
clearTimeout(timer);
reject(e);
});
req.on('timeout', () => {
req.destroy();
clearTimeout(timer);
reject(new Error('Request timed out'));
});
req.write(JSON.stringify(requestData));
req.end();
});
}
export {
generateJTBD,
generateAbstractJTBD
};