added openrouter ai generated tasks

This commit is contained in:
masongga
2026-05-23 23:20:41 -06:00
parent 5f0bbaf3cf
commit c5b20e094e
15 changed files with 1213 additions and 87 deletions

View File

@@ -1,3 +1,13 @@
# Copy this file to .env.local and fill in the values from Supabase > Project Settings > API # Copy this file to .env.local and fill in the values from Supabase > Project Settings > API
NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY= 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

View File

@@ -7,7 +7,7 @@ export default function AvatarPage() {
return ( return (
<AppShell <AppShell
title="Your Habit Pet" title="Your Habit Pet"
description="Today's tasks are personalized from your preferences and daily quiz." description="Today's tasks are personalized by AI from your quiz, journal, and preferences."
> >
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<AvatarStatusCard /> <AvatarStatusCard />

View File

@@ -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 },
);
}
}

View File

@@ -38,8 +38,7 @@ type HabitTrackerProps = {
const reasonLabels = { const reasonLabels = {
custom: "Custom", custom: "Custom",
focus: "Your focus", suggested: "Suggested for you",
quiz: "Today's quiz",
} as const; } as const;
function DailyTaskList({ function DailyTaskList({
@@ -58,8 +57,8 @@ function DailyTaskList({
if (tasks.length === 0) { if (tasks.length === 0) {
return ( return (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No daily tasks yet. Set focus topics in Profile or complete the daily No daily tasks yet. Complete your daily quiz or update your profile so
quiz to personalize your list. AI can suggest habits for you.
</p> </p>
); );
} }
@@ -111,12 +110,22 @@ function DailyTaskList({
<Label htmlFor={habit.id} className="leading-snug"> <Label htmlFor={habit.id} className="leading-snug">
{habit.label} {habit.label}
</Label> </Label>
<Badge <div className="flex flex-wrap gap-1.5">
variant="outline" <Badge
className="w-fit px-2.5 py-0.5 text-[11px] font-normal" variant="outline"
> className="w-fit px-2.5 py-0.5 text-[11px] font-normal"
{reasonLabels[habit.reason]} >
</Badge> {reasonLabels[habit.reason]}
</Badge>
{habit.reason === "suggested" ? (
<Badge
variant="secondary"
className="w-fit px-2.5 py-0.5 text-[11px] font-normal"
>
by AI
</Badge>
) : null}
</div>
{toggleError ? ( {toggleError ? (
<p className="text-xs text-red-500">{toggleError}</p> <p className="text-xs text-red-500">{toggleError}</p>
) : null} ) : null}
@@ -351,7 +360,9 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
return ( return (
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<p className="text-sm text-muted-foreground">Loading habits...</p> <p className="text-sm text-muted-foreground">
Loading and personalizing your tasks...
</p>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -400,8 +411,8 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
<CardHeader> <CardHeader>
<CardTitle className="text-base">How daily tasks are picked</CardTitle> <CardTitle className="text-base">How daily tasks are picked</CardTitle>
<CardDescription> <CardDescription>
Your tasks combine focus preferences, custom habits, and today&apos;s Your tasks combine custom habits and AI suggestions based on your
quiz. profile, onboarding, daily quiz, and journal.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground"> <CardContent className="space-y-3 text-sm text-muted-foreground">
@@ -414,8 +425,8 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
{quizCompletedToday ? "Completed" : "Not completed yet"} {quizCompletedToday ? "Completed" : "Not completed yet"}
</p> </p>
<p> <p>
Suggested habits can appear when your quiz shows low energy, high Suggested tasks are generated daily from your preferences, onboarding
stress, or poor sleep. answers, wellness check-in, and journal.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -428,8 +439,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
<CardHeader> <CardHeader>
<CardTitle className="text-base">Today&apos;s tasks</CardTitle> <CardTitle className="text-base">Today&apos;s tasks</CardTitle>
<CardDescription> <CardDescription>
Personalized from your focus topics Personalized from your profile, daily quiz, journal, and custom habits.
{quizCompletedToday ? ", today's quiz," : ""} and custom habits.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">

View File

@@ -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<ProfilePreferences>;
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<string, unknown> | undefined,
): OnboardingAnswers {
const answers = metadata?.onboarding_answers;
if (!answers || typeof answers !== "object") {
return {
focusTopic: null,
avatarVibe: null,
};
}
const record = answers as Record<string, string>;
return {
focusTopic: record.focus ?? null,
avatarVibe: record.avatarVibe ?? record["pet-name"] ?? null,
};
}
async function loadAiTaskContext(
supabase: SupabaseClient,
userId: string,
userMetadata: Record<string, unknown> | undefined,
): Promise<AiTaskContext> {
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<string | string[]> = [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<string, unknown> | undefined,
): Promise<GenerateDailyTasksResult> {
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,
};
}

39
lib/ai-daily-tasks.ts Normal file
View File

@@ -0,0 +1,39 @@
export type EnsureAiDailyTasksResult = {
generated: boolean;
source?: "openrouter" | "fallback" | "cache";
tasks?: string[];
error?: string;
};
let inflightGeneration: Promise<EnsureAiDailyTasksResult> | null = null;
async function requestAiDailyTasks(): Promise<EnsureAiDailyTasksResult> {
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<EnsureAiDailyTasksResult> {
if (inflightGeneration) {
return inflightGeneration;
}
inflightGeneration = requestAiDailyTasks().finally(() => {
inflightGeneration = null;
});
return inflightGeneration;
}

56
lib/ai-habit-utils.ts Normal file
View File

@@ -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<string>();
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 });
}

252
lib/ai-journal-hints.ts Normal file
View File

@@ -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<string>,
) {
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<string>();
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<string>,
context: AiTaskContext,
) {
const plausible = sanitizePlausibleAiTaskLabels(Array.from(labels));
if (plausible.length >= 2) {
return plausible;
}
const next = new Set<string>(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<string>(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);
}

91
lib/ai-task-context.ts Normal file
View File

@@ -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");
}

25
lib/ai-task-fallback.ts Normal file
View File

@@ -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<string>(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));
}

73
lib/ai-task-guardrails.ts Normal file
View File

@@ -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<string, string[]> = {
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);
}

72
lib/ai-task-metadata.ts Normal file
View File

@@ -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",
};
}

View File

@@ -1,68 +1,14 @@
import type { DailyQuizAnswers } from "@/lib/avatar-state"; import { ensureAiDailyTasks } from "@/lib/ai-daily-tasks";
import { getDailyEntryForToday } from "@/lib/daily-quiz-storage"; import { getHabits, type Habit } from "@/lib/habits-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";
export type DailyTaskReason = "custom" | "focus" | "quiz"; export type DailyTaskReason = "custom" | "suggested";
export type DailyTask = Habit & { export type DailyTask = Habit & {
reason: DailyTaskReason; reason: DailyTaskReason;
}; };
async function getQuizAnswersForToday(): Promise<DailyQuizAnswers | null> {
const entry = await getDailyEntryForToday();
return entry?.answers ?? null;
}
function getCatalogTaskReason(
entry: NonNullable<ReturnType<typeof getCatalogEntry>>,
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<DailyTask[]> { export async function getDailyTasks(): Promise<DailyTask[]> {
const preferences = await getProfilePreferences(); await ensureAiDailyTasks();
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)),
);
const habits = await getHabits(); const habits = await getHabits();
@@ -70,14 +16,11 @@ export async function getDailyTasks(): Promise<DailyTask[]> {
.filter((habit) => habit.isCustom) .filter((habit) => habit.isCustom)
.map((habit) => ({ ...habit, reason: "custom" as const })); .map((habit) => ({ ...habit, reason: "custom" as const }));
const catalogTasks: DailyTask[] = selectedCatalogIds const suggestedTasks: DailyTask[] = habits
.map(({ catalogId, reason }) => { .filter((habit) => habit.isAiGenerated)
const habit = habits.find((item) => item.catalogId === catalogId); .map((habit) => ({ ...habit, reason: "suggested" as const }));
return habit ? { ...habit, reason } : null;
})
.filter((task): task is DailyTask => task !== null);
return [...customTasks, ...catalogTasks]; return [...customTasks, ...suggestedTasks];
} }
export { isCatalogHabitId } from "@/lib/habits-storage"; export { isCatalogHabitId } from "@/lib/habits-storage";

View File

@@ -4,6 +4,10 @@ import {
getCatalogIdByLabel, getCatalogIdByLabel,
isCatalogLabel, isCatalogLabel,
} from "@/lib/habit-catalog"; } from "@/lib/habit-catalog";
import {
displayHabitName,
isAiGeneratedHabitName,
} from "@/lib/ai-habit-utils";
import { adjustCoins } from "@/lib/avatar-progression-storage"; import { adjustCoins } from "@/lib/avatar-progression-storage";
import { notifyHabitPetDataUpdated } from "@/lib/app-events"; import { notifyHabitPetDataUpdated } from "@/lib/app-events";
import { import {
@@ -20,6 +24,7 @@ export type Habit = {
streak: number; streak: number;
lastCompletedDate: string | null; lastCompletedDate: string | null;
isCustom: boolean; isCustom: boolean;
isAiGenerated: boolean;
}; };
export type ToggleHabitResult = { export type ToggleHabitResult = {
@@ -61,13 +66,17 @@ function shouldResetStreakForMissedDay(
} }
function mapHabitRow(row: HabitRow): Habit { function mapHabitRow(row: HabitRow): Habit {
const displayName = displayHabitName(row.name);
const isAiGenerated = isAiGeneratedHabitName(row.name);
return { return {
id: row.id, id: row.id,
label: row.name, label: displayName,
catalogId: getCatalogIdByLabel(row.name), catalogId: isAiGenerated ? null : getCatalogIdByLabel(displayName),
streak: row.streak ?? 0, streak: row.streak ?? 0,
lastCompletedDate: row.last_completed_on ?? null, 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<Habit[]> {
throw new Error(habitError.message); throw new Error(habitError.message);
} }
if (!habit || isCatalogLabel(habit.name)) { if (!habit || isCatalogLabel(habit.name) || isAiGeneratedHabitName(habit.name)) {
return getHabits(); return getHabits();
} }

94
lib/openrouter.ts Normal file
View File

@@ -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<string[]> {
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;
}