235 lines
7.7 KiB
JavaScript
235 lines
7.7 KiB
JavaScript
import safeParseAI from '../utils/safeParseAI';
|
|
import { getSuggestionsForIdentity, isIndonesian, setStoryLanguage } from '../utils/helpers';
|
|
import { AI_SERVICE_URL, AI_MODEL } from '../config/keys';
|
|
|
|
function buildPrompt(story) {
|
|
return `You are a thoughtful and emotionally intelligent personal growth coach.
|
|
|
|
A user has written a personal story about their current life and who they want to become.
|
|
|
|
Your job is NOT just to generate habits.
|
|
Your job is to deeply understand the person and reflect their intention back in a meaningful way.
|
|
|
|
---
|
|
|
|
LANGUAGE RULE (CRITICAL):
|
|
Detect the language of the user's story.
|
|
Your ENTIRE output (identity_title, identity_summary, ALL habits) MUST be in the SAME language as the user's story.
|
|
If the story is in Indonesian, respond in Indonesian.
|
|
If the story is in English, respond in English.
|
|
If the story is in any other language, respond in that language.
|
|
|
|
---
|
|
|
|
INSTRUCTIONS:
|
|
|
|
1. Read the user's story carefully.
|
|
2. Understand:
|
|
- their struggles
|
|
- their desires
|
|
- their emotional tone
|
|
|
|
3. Then generate:
|
|
|
|
A. Identity Title
|
|
- A short, powerful sentence
|
|
- Feels personal and motivating
|
|
|
|
B. Identity Summary
|
|
- 2-3 sentences
|
|
- Reflect their story in a human tone
|
|
- Make it feel like someone truly understands them
|
|
- Avoid generic phrases
|
|
|
|
C. Suggested Habits — generate exactly 20 habit objects
|
|
- The FIRST 5 are TOP PRIORITY (most important, most aligned with story)
|
|
- The remaining 15 are additional recommendations
|
|
- All 20 must be simple, actionable, and doable daily
|
|
- Habits should feel like "small proof of identity"
|
|
- Make each habit unique — no duplicates
|
|
|
|
Each habit MUST be an object with these fields:
|
|
- "title": short habit name (max 8 words)
|
|
- "best_time": recommended time of day to do it (e.g. "06:00", "07:30", "12:00", "18:00", "21:00", "05:30"). Use 24-hour format. Pick a realistic time that fits the activity.
|
|
- "duration": how long per session in minutes as a number (e.g. 5, 10, 15, 20, 30, 45, 60). Must be a number, not a string.
|
|
- "frequency": how often (e.g. "Every day", "3x per week", "Every morning", "Every evening", "Weekdays only")
|
|
- "category": one of: "Mind", "Body", "Emotion", "Social", "Skill", "Discipline", "Health", "Creative"
|
|
- "difficulty": one of: "Easy", "Medium", "Hard"
|
|
- "why": 1 short sentence explaining why this habit matters for their identity
|
|
|
|
D. Tone Style:
|
|
- Warm, supportive, slightly motivational
|
|
- Never robotic or overly formal
|
|
- Avoid buzzwords like "optimize", "maximize"
|
|
|
|
---
|
|
|
|
OUTPUT FORMAT (JSON ONLY):
|
|
Return ONLY valid JSON. No explanation, no markdown, no extra text.
|
|
|
|
{
|
|
"identity_title": "",
|
|
"identity_summary": "",
|
|
"priority_habits": [
|
|
{
|
|
"title": "",
|
|
"best_time": "06:00",
|
|
"duration": 15,
|
|
"frequency": "",
|
|
"category": "",
|
|
"difficulty": "",
|
|
"why": ""
|
|
}
|
|
],
|
|
"suggested_habits": [
|
|
{
|
|
"title": "",
|
|
"best_time": "07:00",
|
|
"duration": 10,
|
|
"frequency": "",
|
|
"category": "",
|
|
"difficulty": "",
|
|
"why": ""
|
|
}
|
|
]
|
|
}
|
|
|
|
---
|
|
|
|
User story:
|
|
"""
|
|
${story}
|
|
"""`;
|
|
}
|
|
|
|
async function callAI(prompt) {
|
|
const response = await fetch(`${AI_SERVICE_URL}/v1/chat/completions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: AI_MODEL,
|
|
max_tokens: 4096,
|
|
messages: [{ role: 'user', content: prompt }],
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errText = await response.text();
|
|
console.warn('AI API error:', response.status, errText);
|
|
throw new Error(`AI request failed (${response.status})`);
|
|
}
|
|
|
|
const json = await response.json();
|
|
return json?.choices?.[0]?.message?.content || '';
|
|
}
|
|
|
|
/**
|
|
* Generate identity + habits from user's personal story using AI.
|
|
* Falls back to local generation if AI fails.
|
|
*/
|
|
export async function generateFromStory(story) {
|
|
// Set language detection from the full story BEFORE anything else
|
|
setStoryLanguage(story);
|
|
|
|
try {
|
|
const rawText = await callAI(buildPrompt(story));
|
|
const parsed = safeParseAI(rawText);
|
|
|
|
if (
|
|
parsed.identity_title &&
|
|
parsed.identity_summary &&
|
|
(parsed.priority_habits?.length > 0 || parsed.suggested_habits?.length > 0)
|
|
) {
|
|
return {
|
|
identity_title: parsed.identity_title,
|
|
identity_summary: parsed.identity_summary,
|
|
priority_habits: (parsed.priority_habits || []).slice(0, 5),
|
|
suggested_habits: (parsed.suggested_habits || []).slice(0, 15),
|
|
source: 'ai',
|
|
};
|
|
}
|
|
|
|
throw new Error('Invalid AI response');
|
|
} catch (e) {
|
|
console.warn('AI generation failed, using fallback:', e.message);
|
|
return generateFallback(story);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Local fallback when AI is unavailable.
|
|
*/
|
|
function generateFallback(story) {
|
|
const words = story.toLowerCase();
|
|
|
|
// Extract identity title from story patterns
|
|
let title = 'A better version of myself';
|
|
const patterns = [
|
|
{ match: /want to be(come)?\s+(.+?)[\.\,\!\n]/i, group: 2 },
|
|
{ match: /i want to\s+(.+?)[\.\,\!\n]/i, group: 1 },
|
|
{ match: /dream of\s+(.+?)[\.\,\!\n]/i, group: 1 },
|
|
{ match: /goal is to\s+(.+?)[\.\,\!\n]/i, group: 1 },
|
|
{ match: /i wish i (was|were|could)\s+(.+?)[\.\,\!\n]/i, group: 2 },
|
|
{ match: /i need to\s+(.+?)[\.\,\!\n]/i, group: 1 },
|
|
{ match: /i('m| am) tired of\s+(.+?)[\.\,\!\n]/i, group: 2 },
|
|
{ match: /i struggle with\s+(.+?)[\.\,\!\n]/i, group: 1 },
|
|
];
|
|
|
|
for (const p of patterns) {
|
|
const m = story.match(p.match);
|
|
if (m && m[p.group]) {
|
|
let extracted = m[p.group].trim();
|
|
// Convert negative patterns to positive identity
|
|
if (p.match.source.includes('tired of') || p.match.source.includes('struggle')) {
|
|
extracted = 'overcoming ' + extracted;
|
|
}
|
|
title = extracted.charAt(0).toUpperCase() + extracted.slice(1);
|
|
if (title.length > 50) title = title.slice(0, 50);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Generate bilingual habits — pass story as both search text AND language text
|
|
const { generateHabitsFromIdentity } = require('../utils/helpers');
|
|
let allHabits = generateHabitsFromIdentity(story, story);
|
|
|
|
// If not enough from story keywords, also try title keywords
|
|
if (allHabits.length < 20) {
|
|
const titleHabits = generateHabitsFromIdentity(title, story);
|
|
titleHabits.forEach((h) => {
|
|
if (!allHabits.some((a) => a.title === h.title)) allHabits.push(h);
|
|
});
|
|
}
|
|
|
|
// Ensure at least 20
|
|
allHabits = allHabits.slice(0, 20);
|
|
|
|
const useId = isIndonesian(story);
|
|
const times = ['06:00', '06:30', '07:00', '07:30', '08:00', '12:00', '17:00', '18:00', '19:00', '20:00', '21:00', '21:30'];
|
|
const durations = [5, 10, 15, 20, 30, 10, 15, 20, 5, 10];
|
|
|
|
const toRichHabit = (h, i) => ({
|
|
title: h.title || h,
|
|
best_time: times[i % times.length],
|
|
duration: durations[i % durations.length],
|
|
frequency: i < 5
|
|
? (useId ? 'Setiap hari' : 'Every day')
|
|
: [useId ? 'Setiap hari' : 'Every day', '3x per week', useId ? 'Setiap pagi' : 'Every morning', useId ? 'Setiap malam' : 'Every evening', useId ? 'Hari kerja' : 'Weekdays only'][i % 5],
|
|
category: ['Discipline', 'Body', 'Mind', 'Emotion', 'Health', 'Skill', 'Social', 'Creative'][i % 8],
|
|
difficulty: i < 3 ? 'Easy' : i < 7 ? 'Medium' : 'Hard',
|
|
why: h.description || '',
|
|
});
|
|
|
|
const summary = useId
|
|
? `Berdasarkan ceritamu, perjalanan ini tentang menjadi ${title.toLowerCase()}. Setiap hari adalah langkah lebih dekat menuju dirimu yang baru.`
|
|
: `Based on your story, this journey is about becoming ${title.toLowerCase()}. Every day is a step closer to the person you described.`;
|
|
|
|
return {
|
|
identity_title: title,
|
|
identity_summary: summary,
|
|
priority_habits: allHabits.slice(0, 5).map((h, i) => toRichHabit(h, i)),
|
|
suggested_habits: allHabits.slice(5, 20).map((h, i) => toRichHabit(h, i + 5)),
|
|
source: 'fallback',
|
|
};
|
|
}
|