Files
Nova40/src/services/aiService.js
T
2026-05-11 11:56:57 +07:00

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',
};
}