From c5b20e094efb2ad29aba21f102bee03fda3f4a97 Mon Sep 17 00:00:00 2001 From: masongga Date: Sat, 23 May 2026 23:20:41 -0600 Subject: [PATCH] added openrouter ai generated tasks --- .env.example | 10 + app/(app)/avatar/page.tsx | 2 +- app/api/generate-daily-tasks/route.ts | 45 +++ components/habits/habit-tracker.tsx | 44 +-- lib/ai-daily-tasks-server.ts | 407 ++++++++++++++++++++++++++ lib/ai-daily-tasks.ts | 39 +++ lib/ai-habit-utils.ts | 56 ++++ lib/ai-journal-hints.ts | 252 ++++++++++++++++ lib/ai-task-context.ts | 91 ++++++ lib/ai-task-fallback.ts | 25 ++ lib/ai-task-guardrails.ts | 73 +++++ lib/ai-task-metadata.ts | 72 +++++ lib/daily-tasks.ts | 73 +---- lib/habits-storage.ts | 17 +- lib/openrouter.ts | 94 ++++++ 15 files changed, 1213 insertions(+), 87 deletions(-) create mode 100644 app/api/generate-daily-tasks/route.ts create mode 100644 lib/ai-daily-tasks-server.ts create mode 100644 lib/ai-daily-tasks.ts create mode 100644 lib/ai-habit-utils.ts create mode 100644 lib/ai-journal-hints.ts create mode 100644 lib/ai-task-context.ts create mode 100644 lib/ai-task-fallback.ts create mode 100644 lib/ai-task-guardrails.ts create mode 100644 lib/ai-task-metadata.ts create mode 100644 lib/openrouter.ts diff --git a/.env.example b/.env.example index 3a3e751..46e8049 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,13 @@ # Copy this file to .env.local and fill in the values from Supabase > Project Settings > API NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= + +# OpenRouter — used to generate personalized daily habit tasks +# Get a key at https://openrouter.ai/keys +OPENROUTER_API_KEY= + +# Optional: override the default free model (google/gemma-2-9b-it:free) +# OPENROUTER_MODEL=meta-llama/llama-3.2-3b-instruct:free + +# Optional: used as HTTP-Referer header for OpenRouter +# NEXT_PUBLIC_SITE_URL=http://localhost:3000 diff --git a/app/(app)/avatar/page.tsx b/app/(app)/avatar/page.tsx index da2310a..5f5d672 100644 --- a/app/(app)/avatar/page.tsx +++ b/app/(app)/avatar/page.tsx @@ -7,7 +7,7 @@ export default function AvatarPage() { return (
diff --git a/app/api/generate-daily-tasks/route.ts b/app/api/generate-daily-tasks/route.ts new file mode 100644 index 0000000..4aec417 --- /dev/null +++ b/app/api/generate-daily-tasks/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; + +import { generateAndSyncDailyTasks } from "@/lib/ai-daily-tasks-server"; +import { createClient } from "@/lib/supabase/server"; + +export async function POST() { + try { + const supabase = await createClient(); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError) { + return NextResponse.json({ error: authError.message }, { status: 401 }); + } + + if (!user) { + return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); + } + + const result = await generateAndSyncDailyTasks( + supabase, + user.id, + user.user_metadata, + ); + + return NextResponse.json(result); + } catch (error) { + console.error("generate-daily-tasks failed:", error); + + return NextResponse.json( + { + generated: false, + source: "fallback", + tasks: [], + error: + error instanceof Error + ? error.message + : "Could not generate daily tasks.", + }, + { status: 500 }, + ); + } +} diff --git a/components/habits/habit-tracker.tsx b/components/habits/habit-tracker.tsx index 6868c11..67d5572 100644 --- a/components/habits/habit-tracker.tsx +++ b/components/habits/habit-tracker.tsx @@ -38,8 +38,7 @@ type HabitTrackerProps = { const reasonLabels = { custom: "Custom", - focus: "Your focus", - quiz: "Today's quiz", + suggested: "Suggested for you", } as const; function DailyTaskList({ @@ -58,8 +57,8 @@ function DailyTaskList({ if (tasks.length === 0) { return (

- No daily tasks yet. Set focus topics in Profile or complete the daily - quiz to personalize your list. + No daily tasks yet. Complete your daily quiz or update your profile so + AI can suggest habits for you.

); } @@ -111,12 +110,22 @@ function DailyTaskList({ - - {reasonLabels[habit.reason]} - +
+ + {reasonLabels[habit.reason]} + + {habit.reason === "suggested" ? ( + + by AI + + ) : null} +
{toggleError ? (

{toggleError}

) : null} @@ -351,7 +360,9 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { return ( -

Loading habits...

+

+ Loading and personalizing your tasks... +

); @@ -400,8 +411,8 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { How daily tasks are picked - Your tasks combine focus preferences, custom habits, and today's - quiz. + Your tasks combine custom habits and AI suggestions based on your + profile, onboarding, daily quiz, and journal. @@ -414,8 +425,8 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { {quizCompletedToday ? "Completed" : "Not completed yet"}

- Suggested habits can appear when your quiz shows low energy, high - stress, or poor sleep. + Suggested tasks are generated daily from your preferences, onboarding + answers, wellness check-in, and journal.

@@ -428,8 +439,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { Today's tasks - Personalized from your focus topics - {quizCompletedToday ? ", today's quiz," : ""} and custom habits. + Personalized from your profile, daily quiz, journal, and custom habits. diff --git a/lib/ai-daily-tasks-server.ts b/lib/ai-daily-tasks-server.ts new file mode 100644 index 0000000..0d5803a --- /dev/null +++ b/lib/ai-daily-tasks-server.ts @@ -0,0 +1,407 @@ +import { + AI_HABIT_NAME_PREFIX, + isAiGeneratedHabitName, + toStoredAiHabitName, +} from "@/lib/ai-habit-utils"; +import { + buildAiTaskContext, + buildAiTaskInputHash, + type AiTaskContext, + type OnboardingAnswers, +} from "@/lib/ai-task-context"; +import { + getAiTaskGenerationMetadata, + mergeAiTaskGenerationMetadata, +} from "@/lib/ai-task-metadata"; +import { buildFallbackAiTaskLabels } from "@/lib/ai-task-fallback"; +import { isCatalogLabel } from "@/lib/habit-catalog"; +import { generateAiTaskLabels } from "@/lib/openrouter"; +import { + defaultProfilePreferences, + type ProfilePreferences, +} from "@/lib/profile-preferences-storage"; +import { getTodayDateKey } from "@/lib/daily-quiz-storage"; +import type { SupabaseClient } from "@supabase/supabase-js"; + +type DailyEntryRow = { + mood: number; + stress: number; + energy: number; + sleep_hours: number; + sleep_quality: number; + journal: string | null; +}; + +function parseProfilePreferences( + focusTopics: string | string[] | null | undefined, +): ProfilePreferences { + const raw = Array.isArray(focusTopics) ? (focusTopics[0] ?? "") : (focusTopics ?? ""); + + if (!raw.trim()) { + return defaultProfilePreferences; + } + + try { + const parsed = JSON.parse(raw) as Partial; + + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return { + focusTopic: parsed.focusTopic ?? defaultProfilePreferences.focusTopic, + avatarVibe: parsed.avatarVibe ?? defaultProfilePreferences.avatarVibe, + dailyReminderEnabled: + parsed.dailyReminderEnabled ?? + defaultProfilePreferences.dailyReminderEnabled, + dailyReminderTime: + parsed.dailyReminderTime ?? + defaultProfilePreferences.dailyReminderTime, + }; + } + } catch { + return { + ...defaultProfilePreferences, + focusTopic: raw, + }; + } + + return defaultProfilePreferences; +} + +function getOnboardingAnswersFromMetadata( + metadata: Record | undefined, +): OnboardingAnswers { + const answers = metadata?.onboarding_answers; + + if (!answers || typeof answers !== "object") { + return { + focusTopic: null, + avatarVibe: null, + }; + } + + const record = answers as Record; + + return { + focusTopic: record.focus ?? null, + avatarVibe: record.avatarVibe ?? record["pet-name"] ?? null, + }; +} + +async function loadAiTaskContext( + supabase: SupabaseClient, + userId: string, + userMetadata: Record | undefined, +): Promise { + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("focus_topics") + .eq("id", userId) + .maybeSingle(); + + if (profileError) { + throw new Error(profileError.message); + } + + const preferences = parseProfilePreferences(profile?.focus_topics); + const onboarding = getOnboardingAnswersFromMetadata(userMetadata); + const entryDate = getTodayDateKey(); + + const { data: dailyEntries, error: dailyError } = await supabase + .from("daily_entries") + .select("mood, stress, energy, sleep_hours, sleep_quality, journal") + .eq("user_id", userId) + .eq("entry_date", entryDate) + .order("created_at", { ascending: false }) + .limit(1); + + if (dailyError) { + throw new Error(dailyError.message); + } + + const dailyEntry = dailyEntries?.[0] as DailyEntryRow | undefined; + + return buildAiTaskContext({ + preferences, + onboarding, + dailyQuiz: dailyEntry + ? { + feeling: dailyEntry.mood, + stress: dailyEntry.stress, + energy: dailyEntry.energy, + sleepLength: Number(dailyEntry.sleep_hours), + sleepQuality: dailyEntry.sleep_quality, + } + : null, + journal: dailyEntry?.journal ?? null, + }); +} + +async function deactivateLegacyCatalogHabits( + supabase: SupabaseClient, + userId: string, +) { + const { data, error } = await supabase + .from("habits") + .select("id, name") + .eq("user_id", userId) + .eq("active", true); + + if (error) { + throw new Error(error.message); + } + + const legacyIds = (data ?? []) + .filter( + (habit) => + !isAiGeneratedHabitName(habit.name) && isCatalogLabel(habit.name), + ) + .map((habit) => habit.id); + + if (legacyIds.length === 0) { + return; + } + + const { error: updateError } = await supabase + .from("habits") + .update({ active: false }) + .in("id", legacyIds); + + if (updateError) { + throw new Error(updateError.message); + } +} + +async function syncAiDailyTasks( + supabase: SupabaseClient, + userId: string, + taskLabels: string[], +) { + const storedNames = taskLabels.map(toStoredAiHabitName); + + const { data: existingAiHabits, error: existingError } = await supabase + .from("habits") + .select("id, name, active") + .eq("user_id", userId) + .like("name", `${AI_HABIT_NAME_PREFIX}%`); + + if (existingError) { + throw new Error(existingError.message); + } + + const existingByName = new Map( + (existingAiHabits ?? []).map((habit) => [habit.name, habit]), + ); + + for (const habit of existingAiHabits ?? []) { + if (storedNames.includes(habit.name)) { + if (!habit.active) { + const { error } = await supabase + .from("habits") + .update({ active: true }) + .eq("id", habit.id); + + if (error) { + throw new Error(error.message); + } + } + + continue; + } + + if (habit.active) { + const { error } = await supabase + .from("habits") + .update({ active: false }) + .eq("id", habit.id); + + if (error) { + throw new Error(error.message); + } + } + } + + for (const storedName of storedNames) { + const existing = existingByName.get(storedName); + + if (existing) { + if (!existing.active) { + const { error } = await supabase + .from("habits") + .update({ active: true }) + .eq("id", existing.id); + + if (error) { + throw new Error(error.message); + } + } + + continue; + } + + const { error: insertError } = await supabase.from("habits").insert({ + user_id: userId, + name: storedName, + active: true, + streak: 0, + }); + + if (!insertError) { + continue; + } + + const { data: racedHabit, error: racedError } = await supabase + .from("habits") + .select("id, active") + .eq("user_id", userId) + .eq("name", storedName) + .maybeSingle(); + + if (racedError) { + throw new Error(racedError.message); + } + + if (!racedHabit) { + throw new Error(insertError.message); + } + + if (!racedHabit.active) { + const { error } = await supabase + .from("habits") + .update({ active: true }) + .eq("id", racedHabit.id); + + if (error) { + throw new Error(error.message); + } + } + } +} + +async function saveGenerationMetadata( + supabase: SupabaseClient, + userId: string, + preferences: ProfilePreferences, + focusTopics: string | string[] | null | undefined, + generatedOn: string, + inputHash: string, +) { + const serialized = mergeAiTaskGenerationMetadata(preferences, { + aiTasksGeneratedOn: generatedOn, + aiTasksInputHash: inputHash, + }); + const attempts: Array = [serialized, [serialized]]; + + for (const focus_topics of attempts) { + const { error } = await supabase + .from("profiles") + .update({ focus_topics }) + .eq("id", userId); + + if (!error) { + return; + } + + if (!/array|malformed|invalid input syntax/i.test(error.message)) { + throw new Error(error.message); + } + } +} + +export type GenerateDailyTasksResult = { + generated: boolean; + tasks: string[]; + source: "openrouter" | "fallback" | "cache"; +}; + +export async function generateAndSyncDailyTasks( + supabase: SupabaseClient, + userId: string, + userMetadata: Record | undefined, +): Promise { + const context = await loadAiTaskContext(supabase, userId, userMetadata); + const inputHash = buildAiTaskInputHash(context); + + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("focus_topics") + .eq("id", userId) + .maybeSingle(); + + if (profileError) { + throw new Error(profileError.message); + } + + const preferences = parseProfilePreferences(profile?.focus_topics); + const metadata = getAiTaskGenerationMetadata(profile?.focus_topics); + + if ( + metadata.aiTasksGeneratedOn === context.date && + metadata.aiTasksInputHash === inputHash + ) { + const { data: activeAiHabits, error: activeError } = await supabase + .from("habits") + .select("name") + .eq("user_id", userId) + .eq("active", true) + .like("name", `${AI_HABIT_NAME_PREFIX}%`); + + if (activeError) { + throw new Error(activeError.message); + } + + return { + generated: false, + source: "cache", + tasks: (activeAiHabits ?? []).map((habit) => + habit.name.slice(AI_HABIT_NAME_PREFIX.length), + ), + }; + } + + let taskLabels: string[] = []; + let source: GenerateDailyTasksResult["source"] = "fallback"; + + try { + taskLabels = await generateAiTaskLabels(context); + source = "openrouter"; + } catch { + taskLabels = buildFallbackAiTaskLabels(context); + source = "fallback"; + } + + try { + await deactivateLegacyCatalogHabits(supabase, userId); + await syncAiDailyTasks(supabase, userId, taskLabels); + await saveGenerationMetadata( + supabase, + userId, + preferences, + profile?.focus_topics, + context.date, + inputHash, + ); + } catch (syncError) { + taskLabels = buildFallbackAiTaskLabels(context); + source = "fallback"; + + await deactivateLegacyCatalogHabits(supabase, userId); + await syncAiDailyTasks(supabase, userId, taskLabels); + await saveGenerationMetadata( + supabase, + userId, + preferences, + profile?.focus_topics, + context.date, + inputHash, + ); + + if (syncError instanceof Error) { + console.error("AI task sync recovered with fallback:", syncError.message); + } + } + + return { + generated: true, + source, + tasks: taskLabels, + }; +} diff --git a/lib/ai-daily-tasks.ts b/lib/ai-daily-tasks.ts new file mode 100644 index 0000000..b9ebe09 --- /dev/null +++ b/lib/ai-daily-tasks.ts @@ -0,0 +1,39 @@ +export type EnsureAiDailyTasksResult = { + generated: boolean; + source?: "openrouter" | "fallback" | "cache"; + tasks?: string[]; + error?: string; +}; + +let inflightGeneration: Promise | null = null; + +async function requestAiDailyTasks(): Promise { + const response = await fetch("/api/generate-daily-tasks", { + method: "POST", + }); + + const payload = (await response.json()) as EnsureAiDailyTasksResult & { + error?: string; + }; + + if (!response.ok) { + return { + generated: false, + error: payload.error ?? "Could not generate daily tasks.", + }; + } + + return payload; +} + +export async function ensureAiDailyTasks(): Promise { + if (inflightGeneration) { + return inflightGeneration; + } + + inflightGeneration = requestAiDailyTasks().finally(() => { + inflightGeneration = null; + }); + + return inflightGeneration; +} diff --git a/lib/ai-habit-utils.ts b/lib/ai-habit-utils.ts new file mode 100644 index 0000000..035040d --- /dev/null +++ b/lib/ai-habit-utils.ts @@ -0,0 +1,56 @@ +import { isPlausibleTaskLabel } from "@/lib/ai-task-guardrails"; + +export const AI_HABIT_NAME_PREFIX = "ai::"; + +export function toStoredAiHabitName(label: string) { + return `${AI_HABIT_NAME_PREFIX}${label.trim()}`; +} + +export function isAiGeneratedHabitName(name: string) { + return name.startsWith(AI_HABIT_NAME_PREFIX); +} + +export function displayHabitName(name: string) { + return isAiGeneratedHabitName(name) + ? name.slice(AI_HABIT_NAME_PREFIX.length) + : name; +} + +export function normalizeAiTaskLabel(label: string) { + return label.trim().replace(/\s+/g, " ").slice(0, 80); +} + +export function sanitizeAiTaskLabels( + labels: unknown, + options?: { requirePlausible?: boolean }, +): string[] { + if (!Array.isArray(labels)) { + return []; + } + + const seen = new Set(); + + return labels + .filter((label): label is string => typeof label === "string") + .map(normalizeAiTaskLabel) + .filter((label) => label.length > 0) + .filter((label) => { + if (options?.requirePlausible && !isPlausibleTaskLabel(label)) { + return false; + } + + const key = label.toLowerCase(); + + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }) + .slice(0, 5); +} + +export function sanitizePlausibleAiTaskLabels(labels: unknown) { + return sanitizeAiTaskLabels(labels, { requirePlausible: true }); +} diff --git a/lib/ai-journal-hints.ts b/lib/ai-journal-hints.ts new file mode 100644 index 0000000..ed6ad76 --- /dev/null +++ b/lib/ai-journal-hints.ts @@ -0,0 +1,252 @@ +import type { AiTaskContext } from "@/lib/ai-task-context"; +import { + normalizeAiTaskLabel, + sanitizePlausibleAiTaskLabels, +} from "@/lib/ai-habit-utils"; +import { + getFocusTopicDefaultTasks, + isPlausibleTaskLabel, +} from "@/lib/ai-task-guardrails"; +const ACTIVITY_PATTERNS: Array<{ pattern: RegExp; label: string }> = [ + { pattern: /\bswim(ming)?\b|\bpool\b/i, label: "Go for a swim" }, + { pattern: /\brun(ning)?\b|\bjog(ging)?\b/i, label: "Go for a short run" }, + { pattern: /\bwalk(ing)?\b|\bhike|hiking\b/i, label: "Go for a walk" }, + { pattern: /\byoga\b/i, label: "Do a yoga session" }, + { pattern: /\bgym\b|\bworkout\b|\bexercise\b/i, label: "Get a workout in" }, + { pattern: /\bbike|biking|cycle|cycling\b/i, label: "Go for a bike ride" }, + { pattern: /\bmeditat(e|ion)\b/i, label: "Meditate for 10 minutes" }, + { pattern: /\bread(ing)?\b/i, label: "Read for 20 minutes" }, + { pattern: /\bstretch(ing)?\b/i, label: "Stretch for 10 minutes" }, + { pattern: /\bcook(ing)?\b|\bmeal prep\b/i, label: "Cook a healthy meal" }, +]; + +const BAD_ACTIVITY_PATTERNS: Array<{ pattern: RegExp; label: string }> = [ + { + pattern: + /\b(scrolling|scroll|doomscroll(?:ing)?|social media|instagram|tiktok|twitter|facebook|snapchat|reddit)\b/i, + label: "Limit social media for 30 minutes", + }, + { + pattern: + /\b(junk food|fast food|sweets|candy|sugar(?:y)?|snack(?:ing)?|chips|soda|pop)\b/i, + label: "Skip sugary snacks today", + }, + { + pattern: /\b(alcohol|drunk|drinking|beer|wine|liquor|hangover)\b/i, + label: "Skip extra drinks today", + }, + { + pattern: /\b(stay(?:ing)? up late|late night|all night|up too late)\b/i, + label: "Start wind-down routine early", + }, + { + pattern: /\b(smok(e|ing)|vap(e|ing)|cigarette|cigarettes|nicotine)\b/i, + label: "Skip smoking today", + }, + { + pattern: /\b(procrastinat\w*|put(?:ting)? off|avoid(?:ing)? work)\b/i, + label: "Complete one task you've been avoiding", + }, + { + pattern: /\b(binge(?:ing)?\s+(?:watch|netflix|shows|tv|youtube|videos))\b/i, + label: "Take a screen break", + }, + { + pattern: /\b(overslept|slept in too long|skipped breakfast|skip(?:ped)? meals?)\b/i, + label: "Eat a proper meal", + }, + { + pattern: /\b(stress eat(?:ing)?|emotional eat(?:ing)?|comfort food)\b/i, + label: "Choose a balanced meal instead", + }, + { + pattern: /\b(too much caffeine|energy drinks?|coffee all day)\b/i, + label: "Cut off caffeine after noon", + }, +]; + +const POSITIVE_INTENT_PATTERN = + /\b(?:want to|would like to|like to|love to|enjoy(?:ed|ing)?|prefer to|hope to|plan to|goal is to|trying to|need to|wish to|looking forward to)\s+([^.!?\n]{3,80})/gi; + +const ENJOY_ACTIVITY_PATTERN = + /\benjoy(?:s|ed|ing)?\s+([^.!?\n]{3,60})/gi; + +const DISLIKE_OR_STRUGGLE_PATTERN = + /\b(?:don't like|do not like|hate|tired of|sick of|struggling with|trying to stop|trying to quit|want to stop|want to quit|need to stop|need to quit|cut down on|reduce|avoid)\s+([^.!?\n]{3,80})/gi; + +const NEGATIVE_GOAL_PREFIX = + /^(?:stop|quit|avoid|reduce|cut down on|less|no more|not)\s+/i; + +const BAD_PHRASE_COUNTERS: Array<{ pattern: RegExp; label: string }> = [ + { + pattern: + /\b(scrolling|scroll|social media|instagram|tiktok|twitter|facebook|phone|screens?)\b/i, + label: "Limit social media for 30 minutes", + }, + { + pattern: /\b(junk food|fast food|sweets|sugar|snack(?:ing)?|soda)\b/i, + label: "Skip sugary snacks today", + }, + { + pattern: /\b(alcohol|drink(?:ing)?|beer|wine)\b/i, + label: "Skip extra drinks today", + }, + { + pattern: /\b(smok(?:e|ing)|vap(?:e|ing)|cigarettes?)\b/i, + label: "Skip smoking today", + }, + { + pattern: /\b(stay(?:ing)? up late|late nights?|all nighters?)\b/i, + label: "Start wind-down routine early", + }, + { + pattern: /\b(procrastinat\w*|putting things off)\b/i, + label: "Complete one task you've been avoiding", + }, + { + pattern: /\b(binge(?:ing)?|netflix|youtube|tv)\b/i, + label: "Take a screen break", + }, +]; + +function titleCasePhrase(value: string) { + return value + .trim() + .split(/\s+/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +} + +function counterTaskForNegativePhrase(phrase: string) { + for (const { pattern, label } of BAD_PHRASE_COUNTERS) { + if (pattern.test(phrase)) { + return label; + } + } + + const fallback = `Limit ${phrase.trim().toLowerCase()} today`; + + return isPlausibleTaskLabel(fallback) ? fallback : null; +} + +function phraseToTaskLabel(phrase: string) { + const cleaned = phrase.trim().replace(/\s+/g, " "); + + if (!cleaned || cleaned.length < 3) { + return null; + } + + if (NEGATIVE_GOAL_PREFIX.test(cleaned)) { + return counterTaskForNegativePhrase( + cleaned.replace(NEGATIVE_GOAL_PREFIX, "").trim(), + ); + } + + for (const { pattern, label } of BAD_ACTIVITY_PATTERNS) { + if (pattern.test(cleaned)) { + return label; + } + } + + for (const { pattern, label } of ACTIVITY_PATTERNS) { + if (pattern.test(cleaned)) { + return label; + } + } + + const titled = titleCasePhrase(cleaned); + + return isPlausibleTaskLabel(titled) ? titled : null; +} + +function addPhraseMatches( + journal: string, + pattern: RegExp, + labels: Set, +) { + for (const match of journal.matchAll(pattern)) { + const label = phraseToTaskLabel(match[1]); + + if (label) { + labels.add(label); + } + } +} + +export function extractJournalTaskHints(journal: string | null | undefined) { + if (!journal?.trim()) { + return []; + } + + const labels = new Set(); + + for (const { pattern, label } of ACTIVITY_PATTERNS) { + if (pattern.test(journal)) { + labels.add(label); + } + } + + for (const { pattern, label } of BAD_ACTIVITY_PATTERNS) { + if (pattern.test(journal)) { + labels.add(label); + } + } + + addPhraseMatches(journal, POSITIVE_INTENT_PATTERN, labels); + addPhraseMatches(journal, ENJOY_ACTIVITY_PATTERN, labels); + addPhraseMatches(journal, DISLIKE_OR_STRUGGLE_PATTERN, labels); + + return sanitizePlausibleAiTaskLabels(Array.from(labels)); +} + +function ensureMinimumFallbackTasks( + labels: Set, + context: AiTaskContext, +) { + const plausible = sanitizePlausibleAiTaskLabels(Array.from(labels)); + + if (plausible.length >= 2) { + return plausible; + } + + const next = new Set(plausible); + + for (const task of getFocusTopicDefaultTasks(context.preferences.focusTopic)) { + next.add(task); + } + + if (next.size < 3) { + next.add("Take a mindful break from screens"); + } + + return sanitizePlausibleAiTaskLabels(Array.from(next)); +} + +export function buildJournalAwareFallbackLabels(context: AiTaskContext): string[] { + const labels = new Set(extractJournalTaskHints(context.journal)); + + if (context.dailyQuiz && context.dailyQuiz.stress >= 4) { + labels.add("Take a calming 10-minute walk"); + labels.add("Try 5 minutes of deep breathing"); + } + + if (context.dailyQuiz && context.dailyQuiz.energy <= 2) { + labels.add("Drink a full glass of water"); + labels.add("Take a short movement break"); + } + + return ensureMinimumFallbackTasks(labels, context); +} + +export function mergeJournalHintsIntoLabels( + labels: string[], + context: AiTaskContext, +): string[] { + const journalHints = extractJournalTaskHints(context.journal); + const merged = sanitizePlausibleAiTaskLabels([ + ...journalHints, + ...labels.map(normalizeAiTaskLabel), + ]); + + return ensureMinimumFallbackTasks(new Set(merged), context); +} diff --git a/lib/ai-task-context.ts b/lib/ai-task-context.ts new file mode 100644 index 0000000..f9f47c4 --- /dev/null +++ b/lib/ai-task-context.ts @@ -0,0 +1,91 @@ +import type { DailyQuizAnswers } from "@/lib/avatar-state"; +import type { ProfilePreferences } from "@/lib/profile-preferences-storage"; +import { getTodayDateKey } from "@/lib/daily-quiz-storage"; + +export type OnboardingAnswers = { + focusTopic: string | null; + avatarVibe: string | null; +}; + +export type AiTaskContext = { + date: string; + preferences: ProfilePreferences; + onboarding: OnboardingAnswers; + dailyQuiz: DailyQuizAnswers | null; + journal: string | null; +}; + +export function buildAiTaskContext(input: { + preferences: ProfilePreferences; + onboarding: OnboardingAnswers; + dailyQuiz: DailyQuizAnswers | null; + journal: string | null; + date?: string; +}): AiTaskContext { + return { + date: input.date ?? getTodayDateKey(), + preferences: input.preferences, + onboarding: input.onboarding, + dailyQuiz: input.dailyQuiz, + journal: input.journal, + }; +} + +export function buildAiTaskInputHash(context: AiTaskContext) { + return JSON.stringify({ + date: context.date, + preferences: context.preferences, + onboarding: context.onboarding, + dailyQuiz: context.dailyQuiz, + journal: context.journal?.trim() ?? "", + }); +} + +export function formatAiTaskContextForPrompt(context: AiTaskContext) { + const { preferences, onboarding, dailyQuiz, journal } = context; + + const quizSection = dailyQuiz + ? [ + `Mood: ${dailyQuiz.feeling}/5`, + `Stress: ${dailyQuiz.stress}/5`, + `Energy: ${dailyQuiz.energy}/5`, + `Sleep: ${dailyQuiz.sleepLength} hours`, + `Sleep quality: ${dailyQuiz.sleepQuality}/5`, + ].join("\n") + : "No daily wellness check-in completed yet today."; + + const journalSection = journal?.trim() + ? journal.trim() + : "No journal entry for today."; + + const stressNote = + dailyQuiz && dailyQuiz.stress >= 4 + ? "Stress is elevated today — include calming or light cardio tasks." + : null; + + const journalNote = journal?.trim() + ? "The journal entry is especially important. If the user mentions wanting to do something specific (like swimming), include a matching check-off task." + : null; + + return [ + "Profile preferences:", + `- Focus topic: ${preferences.focusTopic}`, + `- Avatar vibe: ${preferences.avatarVibe}`, + `- Daily reminder enabled: ${preferences.dailyReminderEnabled ? "yes" : "no"}`, + `- Daily reminder time: ${preferences.dailyReminderTime}`, + "", + "Onboarding quiz answers:", + `- Primary focus: ${onboarding.focusTopic ?? "unknown"}`, + `- Starter pet vibe: ${onboarding.avatarVibe ?? "unknown"}`, + "", + "Today's wellness check-in:", + quizSection, + stressNote ?? "", + "", + "Today's journal (high priority):", + journalSection, + journalNote ?? "", + ] + .filter(Boolean) + .join("\n"); +} diff --git a/lib/ai-task-fallback.ts b/lib/ai-task-fallback.ts new file mode 100644 index 0000000..8359ef6 --- /dev/null +++ b/lib/ai-task-fallback.ts @@ -0,0 +1,25 @@ +import type { AiTaskContext } from "@/lib/ai-task-context"; +import { buildJournalAwareFallbackLabels } from "@/lib/ai-journal-hints"; +import { + habitCatalog, + matchesFocusTopics, + matchesQuizAnswers, +} from "@/lib/habit-catalog"; +import { sanitizePlausibleAiTaskLabels } from "@/lib/ai-habit-utils"; + +export function buildFallbackAiTaskLabels(context: AiTaskContext): string[] { + const labels = new Set(buildJournalAwareFallbackLabels(context)); + + for (const entry of habitCatalog) { + const fromFocus = matchesFocusTopics(entry, context.preferences); + const fromQuiz = context.dailyQuiz + ? matchesQuizAnswers(entry, context.dailyQuiz) + : false; + + if (fromFocus || fromQuiz) { + labels.add(entry.label); + } + } + + return sanitizePlausibleAiTaskLabels(Array.from(labels)); +} diff --git a/lib/ai-task-guardrails.ts b/lib/ai-task-guardrails.ts new file mode 100644 index 0000000..bbb09e4 --- /dev/null +++ b/lib/ai-task-guardrails.ts @@ -0,0 +1,73 @@ +const ABSTRACT_FOCUS_TOPICS = new Set([ + "sleep", + "movement", + "hydration", + "mindfulness", +]); + +const NONSENSE_LABEL_PATTERNS = [ + /^spend \d+ minutes on (sleep|movement|hydration|mindfulness)$/i, + /^(sleep|movement|hydration|mindfulness)$/i, + /^on (sleep|movement|hydration|mindfulness)$/i, + /^focus on (sleep|movement|hydration|mindfulness)$/i, + /^work on (sleep|movement|hydration|mindfulness)$/i, + /^be (sleep|movement|hydration|mindfulness)$/i, + /^more (sleep|movement|hydration|mindfulness)$/i, +]; + +const ACTION_VERB_PATTERN = + /\b(drink|drank|go|went|take|took|limit|limited|skip|skipped|complete|completed|eat|ate|meditate|stretch|stretched|walk|walked|run|ran|swim|swam|read|cook|cooked|wind|avoid|avoided|try|tried|start|started|finish|finished|do|did|get|got|fill|filled|choose|chose|cut|breathe|breathed|hike|hiked|bike|biked|workout|exercise|exercised|journal|write|wrote|call|called|tidy|tidied|clean|cleaned|rest|rested|hydrate|hydrated|move|moved|practice|practiced|put|set|turn|leave|left|join|joined|attend|attended|prepare|prepared|pack|packed|plan|planned|track|tracked|check|checked|brush|brushed|floss|flossed|shower|showered|bathe|bathed|sleep|slept|refill|refilled)\b/i; + +const FOCUS_TOPIC_DEFAULT_TASKS: Record = { + Sleep: ["Wind down before bed", "Avoid screens 30 minutes before sleep"], + Movement: ["Go for a 10-minute walk", "Take a short movement break"], + Hydration: ["Drink a full glass of water", "Refill your water bottle"], + Mindfulness: ["Meditate for 5 minutes", "Take 5 deep breaths"], +}; + +export function getFocusTopicDefaultTasks(focusTopic: string) { + return FOCUS_TOPIC_DEFAULT_TASKS[focusTopic] ?? [ + "Take a mindful break from screens", + "Drink a full glass of water", + ]; +} + +export function isPlausibleTaskLabel(label: string) { + const normalized = normalizeForValidation(label); + + if (normalized.length < 6) { + return false; + } + + const words = normalized.split(/\s+/).filter(Boolean); + + if (words.length < 2) { + return false; + } + + if (ABSTRACT_FOCUS_TOPICS.has(normalized)) { + return false; + } + + if (NONSENSE_LABEL_PATTERNS.some((pattern) => pattern.test(normalized))) { + return false; + } + + if (words.length === 2 && ABSTRACT_FOCUS_TOPICS.has(words[1])) { + return false; + } + + if (!ACTION_VERB_PATTERN.test(normalized)) { + return false; + } + + return true; +} + +function normalizeForValidation(label: string) { + return label.trim().replace(/\s+/g, " ").toLowerCase(); +} + +export function filterPlausibleTaskLabels(labels: string[]) { + return labels.filter(isPlausibleTaskLabel); +} diff --git a/lib/ai-task-metadata.ts b/lib/ai-task-metadata.ts new file mode 100644 index 0000000..e782079 --- /dev/null +++ b/lib/ai-task-metadata.ts @@ -0,0 +1,72 @@ +import type { ProfilePreferences } from "@/lib/profile-preferences-storage"; + +export type AiTaskGenerationMetadata = { + aiTasksGeneratedOn: string | null; + aiTasksInputHash: string | null; +}; + +type StoredProfilePreferences = ProfilePreferences & { + aiTasksGeneratedOn?: string; + aiTasksInputHash?: string; +}; + +export function getAiTaskGenerationMetadata( + focusTopics: string | string[] | null | undefined, +): AiTaskGenerationMetadata { + const parsed = parseStoredPreferences(focusTopics); + + return { + aiTasksGeneratedOn: parsed.aiTasksGeneratedOn ?? null, + aiTasksInputHash: parsed.aiTasksInputHash ?? null, + }; +} + +export function mergeAiTaskGenerationMetadata( + preferences: ProfilePreferences, + metadata: AiTaskGenerationMetadata, +): string { + const payload: StoredProfilePreferences = { + ...preferences, + aiTasksGeneratedOn: metadata.aiTasksGeneratedOn ?? undefined, + aiTasksInputHash: metadata.aiTasksInputHash ?? undefined, + }; + + return JSON.stringify(payload); +} + +function parseStoredPreferences( + value: string | string[] | null | undefined, +): StoredProfilePreferences { + const raw = Array.isArray(value) ? (value[0] ?? "") : (value ?? ""); + + if (!raw.trim()) { + return { + focusTopic: "Movement", + avatarVibe: "Calm", + dailyReminderEnabled: false, + dailyReminderTime: "20:00", + }; + } + + try { + const parsed = JSON.parse(raw) as StoredProfilePreferences; + + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed; + } + } catch { + return { + focusTopic: raw, + avatarVibe: "Calm", + dailyReminderEnabled: false, + dailyReminderTime: "20:00", + }; + } + + return { + focusTopic: "Movement", + avatarVibe: "Calm", + dailyReminderEnabled: false, + dailyReminderTime: "20:00", + }; +} diff --git a/lib/daily-tasks.ts b/lib/daily-tasks.ts index 6ed3cbe..2cf789a 100644 --- a/lib/daily-tasks.ts +++ b/lib/daily-tasks.ts @@ -1,68 +1,14 @@ -import type { DailyQuizAnswers } from "@/lib/avatar-state"; -import { getDailyEntryForToday } from "@/lib/daily-quiz-storage"; -import { - getCatalogEntry, - habitCatalog, - matchesFocusTopics, - matchesQuizAnswers, -} from "@/lib/habit-catalog"; -import { - ensureCatalogHabit, - getHabits, - type Habit, -} from "@/lib/habits-storage"; -import { - getProfilePreferences, - type ProfilePreferences, -} from "@/lib/profile-preferences-storage"; +import { ensureAiDailyTasks } from "@/lib/ai-daily-tasks"; +import { getHabits, type Habit } from "@/lib/habits-storage"; -export type DailyTaskReason = "custom" | "focus" | "quiz"; +export type DailyTaskReason = "custom" | "suggested"; export type DailyTask = Habit & { reason: DailyTaskReason; }; -async function getQuizAnswersForToday(): Promise { - const entry = await getDailyEntryForToday(); - return entry?.answers ?? null; -} - -function getCatalogTaskReason( - entry: NonNullable>, - preferences: ProfilePreferences, - quizAnswers: DailyQuizAnswers | null, -): DailyTaskReason | null { - const fromQuiz = quizAnswers ? matchesQuizAnswers(entry, quizAnswers) : false; - const fromFocus = matchesFocusTopics(entry, preferences); - - if (fromQuiz) { - return "quiz"; - } - - if (fromFocus) { - return "focus"; - } - - return null; -} - export async function getDailyTasks(): Promise { - const preferences = await getProfilePreferences(); - const quizAnswers = await getQuizAnswersForToday(); - - const selectedCatalogIds = habitCatalog - .map((entry) => { - const reason = getCatalogTaskReason(entry, preferences, quizAnswers); - return reason ? { catalogId: entry.id, reason } : null; - }) - .filter( - (item): item is { catalogId: string; reason: DailyTaskReason } => - item !== null, - ); - - await Promise.all( - selectedCatalogIds.map(({ catalogId }) => ensureCatalogHabit(catalogId)), - ); + await ensureAiDailyTasks(); const habits = await getHabits(); @@ -70,14 +16,11 @@ export async function getDailyTasks(): Promise { .filter((habit) => habit.isCustom) .map((habit) => ({ ...habit, reason: "custom" as const })); - const catalogTasks: DailyTask[] = selectedCatalogIds - .map(({ catalogId, reason }) => { - const habit = habits.find((item) => item.catalogId === catalogId); - return habit ? { ...habit, reason } : null; - }) - .filter((task): task is DailyTask => task !== null); + const suggestedTasks: DailyTask[] = habits + .filter((habit) => habit.isAiGenerated) + .map((habit) => ({ ...habit, reason: "suggested" as const })); - return [...customTasks, ...catalogTasks]; + return [...customTasks, ...suggestedTasks]; } export { isCatalogHabitId } from "@/lib/habits-storage"; diff --git a/lib/habits-storage.ts b/lib/habits-storage.ts index 824c5f0..1bd308b 100644 --- a/lib/habits-storage.ts +++ b/lib/habits-storage.ts @@ -4,6 +4,10 @@ import { getCatalogIdByLabel, isCatalogLabel, } from "@/lib/habit-catalog"; +import { + displayHabitName, + isAiGeneratedHabitName, +} from "@/lib/ai-habit-utils"; import { adjustCoins } from "@/lib/avatar-progression-storage"; import { notifyHabitPetDataUpdated } from "@/lib/app-events"; import { @@ -20,6 +24,7 @@ export type Habit = { streak: number; lastCompletedDate: string | null; isCustom: boolean; + isAiGenerated: boolean; }; export type ToggleHabitResult = { @@ -61,13 +66,17 @@ function shouldResetStreakForMissedDay( } function mapHabitRow(row: HabitRow): Habit { + const displayName = displayHabitName(row.name); + const isAiGenerated = isAiGeneratedHabitName(row.name); + return { id: row.id, - label: row.name, - catalogId: getCatalogIdByLabel(row.name), + label: displayName, + catalogId: isAiGenerated ? null : getCatalogIdByLabel(displayName), streak: row.streak ?? 0, lastCompletedDate: row.last_completed_on ?? null, - isCustom: !isCatalogLabel(row.name), + isCustom: !isAiGenerated && !isCatalogLabel(displayName), + isAiGenerated, }; } @@ -414,7 +423,7 @@ export async function removeHabit(habitId: string): Promise { throw new Error(habitError.message); } - if (!habit || isCatalogLabel(habit.name)) { + if (!habit || isCatalogLabel(habit.name) || isAiGeneratedHabitName(habit.name)) { return getHabits(); } diff --git a/lib/openrouter.ts b/lib/openrouter.ts new file mode 100644 index 0000000..5a91dcc --- /dev/null +++ b/lib/openrouter.ts @@ -0,0 +1,94 @@ +import { sanitizePlausibleAiTaskLabels } from "@/lib/ai-habit-utils"; +import { + formatAiTaskContextForPrompt, + type AiTaskContext, +} from "@/lib/ai-task-context"; +import { mergeJournalHintsIntoLabels } from "@/lib/ai-journal-hints"; + +const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; + +export const DEFAULT_OPENROUTER_MODEL = + process.env.OPENROUTER_MODEL ?? "google/gemma-2-9b-it:free"; + +function extractJsonArray(content: string) { + const start = content.indexOf("["); + const end = content.lastIndexOf("]"); + + if (start === -1 || end === -1 || end <= start) { + return null; + } + + try { + return JSON.parse(content.slice(start, end + 1)) as unknown; + } catch { + return null; + } +} + +export async function generateAiTaskLabels( + context: AiTaskContext, +): Promise { + const apiKey = process.env.OPENROUTER_API_KEY; + + if (!apiKey) { + throw new Error("OPENROUTER_API_KEY is not configured."); + } + + const siteUrl = + process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"; + + const response = await fetch(OPENROUTER_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + "HTTP-Referer": siteUrl, + "X-Title": "Habit Pet", + }, + body: JSON.stringify({ + model: DEFAULT_OPENROUTER_MODEL, + temperature: 0.7, + messages: [ + { + role: "system", + content: + "You are a supportive wellness coach for a habit-tracking pet app. Suggest practical, specific daily habits tailored to the user's data. Each task must be a concrete action the user can check off (e.g. 'Drink a full glass of water', 'Go for a swim'). Never suggest vague tasks like 'spend time on hydration' or use abstract focus topics (sleep, movement, hydration, mindfulness) as task names. The journal entry reflects what the user wants to do today — if they mention an activity (e.g. swimming, running, yoga), you MUST include at least one closely related check-off task. If stress is high, include calming or light cardio tasks that fit their journal. Return ONLY a JSON array of 3 to 5 strings. Each string must be a short habit label under 8 words.", + }, + { + role: "user", + content: `${formatAiTaskContextForPrompt(context)} + +Generate today's personalized habit task labels as a JSON array of strings.`, + }, + ], + }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error( + `OpenRouter request failed (${response.status}): ${errorBody.slice(0, 200)}`, + ); + } + + const payload = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + + const content = payload.choices?.[0]?.message?.content?.trim(); + + if (!content) { + throw new Error("OpenRouter returned an empty response."); + } + + const labels = mergeJournalHintsIntoLabels( + sanitizePlausibleAiTaskLabels(extractJsonArray(content) ?? []), + context, + ); + + if (labels.length === 0) { + throw new Error("OpenRouter response did not include valid task labels."); + } + + return labels; +}