ai generated tasks check for duplicates, existing streaks, and more before creation
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
import {
|
||||
addHabit,
|
||||
getCustomHabits,
|
||||
getTodayDateKey,
|
||||
isHabitCompletedToday,
|
||||
removeHabit,
|
||||
toggleHabitCompletion,
|
||||
@@ -198,6 +199,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
|
||||
);
|
||||
const [toggleErrors, setToggleErrors] = useState<Record<string, string>>({});
|
||||
const [coinMessage, setCoinMessage] = useState<string | null>(null);
|
||||
const [todayKey, setTodayKey] = useState(() => getTodayDateKey());
|
||||
|
||||
const getIsCompleted = useCallback(
|
||||
(habit: DailyTask) => {
|
||||
@@ -256,6 +258,37 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
|
||||
};
|
||||
}, [coinMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncForNewDay = () => {
|
||||
const nextTodayKey = getTodayDateKey();
|
||||
|
||||
if (nextTodayKey === todayKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTodayKey(nextTodayKey);
|
||||
setOptimisticCompletion({});
|
||||
setCoinMessage(null);
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const intervalId = window.setInterval(syncForNewDay, 60_000);
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
syncForNewDay();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("focus", syncForNewDay);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
window.removeEventListener("focus", syncForNewDay);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [refresh, todayKey]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
AI_HABIT_NAME_PREFIX,
|
||||
displayHabitName,
|
||||
finalizeDailyAiTaskLabels,
|
||||
isAiGeneratedHabitName,
|
||||
toStoredAiHabitName,
|
||||
} from "@/lib/ai-habit-utils";
|
||||
@@ -16,6 +18,9 @@ import {
|
||||
import { buildFallbackAiTaskLabels } from "@/lib/ai-task-fallback";
|
||||
import { isCatalogLabel } from "@/lib/habit-catalog";
|
||||
import { generateAiTaskLabels } from "@/lib/openrouter";
|
||||
import {
|
||||
getGenerationSlotCount,
|
||||
} from "@/lib/ai-task-generation";
|
||||
import {
|
||||
defaultProfilePreferences,
|
||||
type ProfilePreferences,
|
||||
@@ -32,6 +37,70 @@ type DailyEntryRow = {
|
||||
journal: string | null;
|
||||
};
|
||||
|
||||
type AiHabitRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
streak: number;
|
||||
last_completed_on: string | null;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
function getDayDiff(fromDate: string, toDate: string) {
|
||||
const from = new Date(`${fromDate}T00:00:00`);
|
||||
const to = new Date(`${toDate}T00:00:00`);
|
||||
|
||||
return Math.round((to.getTime() - from.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
function shouldResetStreakForMissedDay(
|
||||
lastCompletedOn: string | null,
|
||||
today: string,
|
||||
) {
|
||||
if (!lastCompletedOn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getDayDiff(lastCompletedOn, today) > 1;
|
||||
}
|
||||
|
||||
async function getStreakCarryOverLabels(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
today: string,
|
||||
) {
|
||||
const { data, error } = await supabase
|
||||
.from("habits")
|
||||
.select("id, name, streak, last_completed_on, active")
|
||||
.eq("user_id", userId)
|
||||
.like("name", `${AI_HABIT_NAME_PREFIX}%`)
|
||||
.gt("streak", 0);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
const carryOverLabels: string[] = [];
|
||||
|
||||
for (const habit of (data ?? []) as AiHabitRow[]) {
|
||||
if (shouldResetStreakForMissedDay(habit.last_completed_on, today)) {
|
||||
const { error: resetError } = await supabase
|
||||
.from("habits")
|
||||
.update({ streak: 0, claimed_streak_milestones: [] })
|
||||
.eq("id", habit.id);
|
||||
|
||||
if (resetError) {
|
||||
throw new Error(resetError.message);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
carryOverLabels.push(displayHabitName(habit.name));
|
||||
}
|
||||
|
||||
return carryOverLabels;
|
||||
}
|
||||
|
||||
function parseProfilePreferences(
|
||||
focusTopics: string | string[] | null | undefined,
|
||||
): ProfilePreferences {
|
||||
@@ -319,6 +388,11 @@ export async function generateAndSyncDailyTasks(
|
||||
): Promise<GenerateDailyTasksResult> {
|
||||
const context = await loadAiTaskContext(supabase, userId, userMetadata);
|
||||
const inputHash = buildAiTaskInputHash(context);
|
||||
const carryOverLabels = await getStreakCarryOverLabels(
|
||||
supabase,
|
||||
userId,
|
||||
context.date,
|
||||
);
|
||||
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from("profiles")
|
||||
@@ -351,23 +425,41 @@ export async function generateAndSyncDailyTasks(
|
||||
return {
|
||||
generated: false,
|
||||
source: "cache",
|
||||
tasks: (activeAiHabits ?? []).map((habit) =>
|
||||
habit.name.slice(AI_HABIT_NAME_PREFIX.length),
|
||||
tasks: finalizeDailyAiTaskLabels(
|
||||
carryOverLabels,
|
||||
(activeAiHabits ?? [])
|
||||
.map((habit) => displayHabitName(habit.name))
|
||||
.filter(
|
||||
(label) =>
|
||||
!carryOverLabels.some(
|
||||
(carryOver) => carryOver.toLowerCase() === label.toLowerCase(),
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let taskLabels: string[] = [];
|
||||
const slotsNeeded = getGenerationSlotCount(carryOverLabels.length);
|
||||
let newLabels: string[] = [];
|
||||
let source: GenerateDailyTasksResult["source"] = "fallback";
|
||||
|
||||
try {
|
||||
taskLabels = await generateAiTaskLabels(context);
|
||||
source = "openrouter";
|
||||
} catch {
|
||||
taskLabels = buildFallbackAiTaskLabels(context);
|
||||
source = "fallback";
|
||||
if (slotsNeeded > 0) {
|
||||
const generationOptions = {
|
||||
excludeLabels: carryOverLabels,
|
||||
maxCount: slotsNeeded,
|
||||
};
|
||||
|
||||
try {
|
||||
newLabels = await generateAiTaskLabels(context, generationOptions);
|
||||
source = "openrouter";
|
||||
} catch {
|
||||
newLabels = buildFallbackAiTaskLabels(context, generationOptions);
|
||||
source = "fallback";
|
||||
}
|
||||
}
|
||||
|
||||
const taskLabels = finalizeDailyAiTaskLabels(carryOverLabels, newLabels);
|
||||
|
||||
try {
|
||||
await deactivateLegacyCatalogHabits(supabase, userId);
|
||||
await syncAiDailyTasks(supabase, userId, taskLabels);
|
||||
@@ -380,11 +472,19 @@ export async function generateAndSyncDailyTasks(
|
||||
inputHash,
|
||||
);
|
||||
} catch (syncError) {
|
||||
taskLabels = buildFallbackAiTaskLabels(context);
|
||||
const fallbackLabels = finalizeDailyAiTaskLabels(
|
||||
carryOverLabels,
|
||||
slotsNeeded > 0
|
||||
? buildFallbackAiTaskLabels(context, {
|
||||
excludeLabels: carryOverLabels,
|
||||
maxCount: slotsNeeded,
|
||||
})
|
||||
: [],
|
||||
);
|
||||
source = "fallback";
|
||||
|
||||
await deactivateLegacyCatalogHabits(supabase, userId);
|
||||
await syncAiDailyTasks(supabase, userId, taskLabels);
|
||||
await syncAiDailyTasks(supabase, userId, fallbackLabels);
|
||||
await saveGenerationMetadata(
|
||||
supabase,
|
||||
userId,
|
||||
@@ -397,6 +497,12 @@ export async function generateAndSyncDailyTasks(
|
||||
if (syncError instanceof Error) {
|
||||
console.error("AI task sync recovered with fallback:", syncError.message);
|
||||
}
|
||||
|
||||
return {
|
||||
generated: true,
|
||||
source,
|
||||
tasks: fallbackLabels,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { isPlausibleTaskLabel } from "@/lib/ai-task-guardrails";
|
||||
import {
|
||||
dedupeSimilarTaskLabels,
|
||||
filterExcludedSimilarTaskLabels,
|
||||
isPlausibleTaskLabel,
|
||||
} from "@/lib/ai-task-guardrails";
|
||||
import {
|
||||
MAX_DAILY_AI_TASKS,
|
||||
type AiTaskGenerationOptions,
|
||||
} from "@/lib/ai-task-generation";
|
||||
|
||||
export const AI_HABIT_NAME_PREFIX = "ai::";
|
||||
|
||||
@@ -30,7 +38,7 @@ export function sanitizeAiTaskLabels(
|
||||
|
||||
const seen = new Set<string>();
|
||||
|
||||
return labels
|
||||
const filtered = labels
|
||||
.filter((label): label is string => typeof label === "string")
|
||||
.map(normalizeAiTaskLabel)
|
||||
.filter((label) => label.length > 0)
|
||||
@@ -47,10 +55,38 @@ export function sanitizeAiTaskLabels(
|
||||
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 5);
|
||||
});
|
||||
|
||||
return dedupeSimilarTaskLabels(filtered).slice(0, 5);
|
||||
}
|
||||
|
||||
export function sanitizePlausibleAiTaskLabels(labels: unknown) {
|
||||
return sanitizeAiTaskLabels(labels, { requirePlausible: true });
|
||||
}
|
||||
|
||||
export function finalizeDailyAiTaskLabels(
|
||||
carryOverLabels: string[],
|
||||
newLabels: string[],
|
||||
maxCount = MAX_DAILY_AI_TASKS,
|
||||
) {
|
||||
return dedupeSimilarTaskLabels(
|
||||
sanitizePlausibleAiTaskLabels([...carryOverLabels, ...newLabels]),
|
||||
).slice(0, maxCount);
|
||||
}
|
||||
|
||||
export function finalizeNewAiTaskLabels(
|
||||
newLabels: string[],
|
||||
options?: AiTaskGenerationOptions,
|
||||
) {
|
||||
const maxCount = options?.maxCount ?? MAX_DAILY_AI_TASKS;
|
||||
const excludeLabels = options?.excludeLabels ?? [];
|
||||
|
||||
return dedupeSimilarTaskLabels(
|
||||
filterExcludedSimilarTaskLabels(
|
||||
sanitizePlausibleAiTaskLabels(newLabels),
|
||||
excludeLabels,
|
||||
),
|
||||
).slice(0, maxCount);
|
||||
}
|
||||
|
||||
export { MAX_DAILY_AI_TASKS };
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
sanitizePlausibleAiTaskLabels,
|
||||
} from "@/lib/ai-habit-utils";
|
||||
import {
|
||||
filterExcludedSimilarTaskLabels,
|
||||
getFocusTopicDefaultTasks,
|
||||
isPlausibleTaskLabel,
|
||||
} from "@/lib/ai-task-guardrails";
|
||||
@@ -241,12 +242,29 @@ export function buildJournalAwareFallbackLabels(context: AiTaskContext): string[
|
||||
export function mergeJournalHintsIntoLabels(
|
||||
labels: string[],
|
||||
context: AiTaskContext,
|
||||
): string[] {
|
||||
options?: {
|
||||
fillInOnly?: boolean;
|
||||
maxCount?: number;
|
||||
excludeLabels?: string[];
|
||||
},
|
||||
) {
|
||||
const journalHints = extractJournalTaskHints(context.journal);
|
||||
const merged = sanitizePlausibleAiTaskLabels([
|
||||
let merged = sanitizePlausibleAiTaskLabels([
|
||||
...journalHints,
|
||||
...labels.map(normalizeAiTaskLabel),
|
||||
]);
|
||||
|
||||
if (options?.excludeLabels?.length) {
|
||||
merged = filterExcludedSimilarTaskLabels(merged, options.excludeLabels);
|
||||
}
|
||||
|
||||
if (options?.maxCount !== undefined) {
|
||||
merged = merged.slice(0, options.maxCount);
|
||||
}
|
||||
|
||||
if (options?.fillInOnly) {
|
||||
return merged;
|
||||
}
|
||||
|
||||
return ensureMinimumFallbackTasks(new Set(merged), context);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,16 @@ import {
|
||||
matchesFocusTopics,
|
||||
matchesQuizAnswers,
|
||||
} from "@/lib/habit-catalog";
|
||||
import { sanitizePlausibleAiTaskLabels } from "@/lib/ai-habit-utils";
|
||||
import { finalizeNewAiTaskLabels } from "@/lib/ai-habit-utils";
|
||||
import {
|
||||
MAX_DAILY_AI_TASKS,
|
||||
type AiTaskGenerationOptions,
|
||||
} from "@/lib/ai-task-generation";
|
||||
|
||||
export function buildFallbackAiTaskLabels(context: AiTaskContext): string[] {
|
||||
export function buildFallbackAiTaskLabels(
|
||||
context: AiTaskContext,
|
||||
options?: AiTaskGenerationOptions,
|
||||
): string[] {
|
||||
const labels = new Set<string>(buildJournalAwareFallbackLabels(context));
|
||||
|
||||
for (const entry of habitCatalog) {
|
||||
@@ -21,5 +28,8 @@ export function buildFallbackAiTaskLabels(context: AiTaskContext): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
return sanitizePlausibleAiTaskLabels(Array.from(labels));
|
||||
return finalizeNewAiTaskLabels(Array.from(labels), {
|
||||
excludeLabels: options?.excludeLabels,
|
||||
maxCount: options?.maxCount ?? MAX_DAILY_AI_TASKS,
|
||||
});
|
||||
}
|
||||
|
||||
34
lib/ai-task-generation.ts
Normal file
34
lib/ai-task-generation.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { AiTaskContext } from "@/lib/ai-task-context";
|
||||
|
||||
export const MAX_DAILY_AI_TASKS = 5;
|
||||
|
||||
export type AiTaskGenerationOptions = {
|
||||
excludeLabels?: string[];
|
||||
maxCount?: number;
|
||||
};
|
||||
|
||||
export function getGenerationSlotCount(
|
||||
carryOverCount: number,
|
||||
maxCount = MAX_DAILY_AI_TASKS,
|
||||
) {
|
||||
return Math.max(0, maxCount - carryOverCount);
|
||||
}
|
||||
|
||||
export function buildGenerationUserPrompt(
|
||||
context: AiTaskContext,
|
||||
options?: AiTaskGenerationOptions,
|
||||
) {
|
||||
const maxCount = options?.maxCount ?? MAX_DAILY_AI_TASKS;
|
||||
const excludeLabels = options?.excludeLabels ?? [];
|
||||
|
||||
if (excludeLabels.length === 0) {
|
||||
return `Generate today's personalized habit task labels as a JSON array of up to ${maxCount} strings.`;
|
||||
}
|
||||
|
||||
return [
|
||||
`Generate exactly ${maxCount} NEW habit task labels as a JSON array of strings.`,
|
||||
"Do not repeat or rephrase any of these existing tasks the user already has:",
|
||||
...excludeLabels.map((label) => `- ${label}`),
|
||||
"Use different wording only for genuinely different activities.",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -15,6 +15,30 @@ const NONSENSE_LABEL_PATTERNS = [
|
||||
/^more (sleep|movement|hydration|mindfulness)$/i,
|
||||
];
|
||||
|
||||
const VAGUE_GERUND_LABEL_PATTERNS = [
|
||||
/^(running|swimming|walking|jogging|reading|stretching|cooking|hiking|biking|cycling|meditating|exercising|studying|cleaning)$/i,
|
||||
/^go (running|swimming|walking|jogging|reading|stretching|cooking|hiking|biking|cycling|meditating|exercising)$/i,
|
||||
/^try (running|swimming|walking|jogging|reading|stretching|cooking|hiking|biking|cycling|meditating|exercising)$/i,
|
||||
/^do (running|swimming|walking|jogging|reading|stretching|cooking|hiking|biking|cycling|meditating|exercising)$/i,
|
||||
];
|
||||
|
||||
const ACTIVITY_KEY_PATTERNS: Array<{ key: string; pattern: RegExp }> = [
|
||||
{ key: "run", pattern: /\b(run|running|jog|jogging)\b/i },
|
||||
{ key: "swim", pattern: /\b(swim|swimming|pool)\b/i },
|
||||
{ key: "walk", pattern: /\b(walk|walking|hike|hiking)\b/i },
|
||||
{ key: "yoga", pattern: /\byoga\b/i },
|
||||
{ key: "workout", pattern: /\b(workout|gym|exercise|exercising)\b/i },
|
||||
{ key: "bike", pattern: /\b(bike|biking|cycle|cycling)\b/i },
|
||||
{ key: "meditate", pattern: /\b(meditat\w*)\b/i },
|
||||
{ key: "read", pattern: /\b(read|reading)\b/i },
|
||||
{ key: "stretch", pattern: /\b(stretch|stretching)\b/i },
|
||||
{ key: "cook", pattern: /\b(cook|cooking|meal prep)\b/i },
|
||||
{ key: "water", pattern: /\b(water|hydrate|hydration)\b/i },
|
||||
{ key: "sleep", pattern: /\b(sleep|wind down|wind-down|bedtime)\b/i },
|
||||
{ key: "screen", pattern: /\b(screen|social media|scroll|scrolling)\b/i },
|
||||
{ key: "breathe", pattern: /\b(breathe|breathing|meditat\w*)\b/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;
|
||||
|
||||
@@ -53,6 +77,10 @@ export function isPlausibleTaskLabel(label: string) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (VAGUE_GERUND_LABEL_PATTERNS.some((pattern) => pattern.test(normalized))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (words.length === 2 && ABSTRACT_FOCUS_TOPICS.has(words[1])) {
|
||||
return false;
|
||||
}
|
||||
@@ -71,3 +99,101 @@ function normalizeForValidation(label: string) {
|
||||
export function filterPlausibleTaskLabels(labels: string[]) {
|
||||
return labels.filter(isPlausibleTaskLabel);
|
||||
}
|
||||
|
||||
function getActivityKey(label: string) {
|
||||
for (const { key, pattern } of ACTIVITY_KEY_PATTERNS) {
|
||||
if (pattern.test(label)) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function labelSpecificityScore(label: string) {
|
||||
let score = label.length;
|
||||
|
||||
if (/^go for a\b/i.test(label)) {
|
||||
score += 25;
|
||||
}
|
||||
|
||||
if (/\b\d+\b/.test(label)) {
|
||||
score += 8;
|
||||
}
|
||||
|
||||
if (/\b(minute|minutes|hour|hours|glass|bottle)\b/i.test(label)) {
|
||||
score += 6;
|
||||
}
|
||||
|
||||
if (VAGUE_GERUND_LABEL_PATTERNS.some((pattern) => pattern.test(label))) {
|
||||
score -= 40;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
export function dedupeSimilarTaskLabels(labels: string[]) {
|
||||
const ungrouped: string[] = [];
|
||||
const grouped = new Map<string, string>();
|
||||
|
||||
for (const label of labels) {
|
||||
const activityKey = getActivityKey(label);
|
||||
|
||||
if (!activityKey) {
|
||||
ungrouped.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = grouped.get(activityKey);
|
||||
|
||||
if (!existing) {
|
||||
grouped.set(activityKey, label);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (labelSpecificityScore(label) > labelSpecificityScore(existing)) {
|
||||
grouped.set(activityKey, label);
|
||||
}
|
||||
}
|
||||
|
||||
const deduped = [...ungrouped, ...grouped.values()];
|
||||
|
||||
const seen = new Set<string>();
|
||||
|
||||
return deduped.filter((label) => {
|
||||
const key = label.toLowerCase();
|
||||
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function filterExcludedSimilarTaskLabels(
|
||||
labels: string[],
|
||||
excludeLabels: string[],
|
||||
) {
|
||||
if (excludeLabels.length === 0) {
|
||||
return labels;
|
||||
}
|
||||
|
||||
const excludedExact = new Set(excludeLabels.map((label) => label.toLowerCase()));
|
||||
const excludedActivityKeys = new Set(
|
||||
excludeLabels
|
||||
.map((label) => getActivityKey(label))
|
||||
.filter((key): key is string => key !== null),
|
||||
);
|
||||
|
||||
return labels.filter((label) => {
|
||||
if (excludedExact.has(label.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const activityKey = getActivityKey(label);
|
||||
|
||||
return !activityKey || !excludedActivityKeys.has(activityKey);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { sanitizePlausibleAiTaskLabels } from "@/lib/ai-habit-utils";
|
||||
import {
|
||||
finalizeNewAiTaskLabels,
|
||||
sanitizePlausibleAiTaskLabels,
|
||||
} from "@/lib/ai-habit-utils";
|
||||
import {
|
||||
formatAiTaskContextForPrompt,
|
||||
type AiTaskContext,
|
||||
} from "@/lib/ai-task-context";
|
||||
import {
|
||||
buildGenerationUserPrompt,
|
||||
MAX_DAILY_AI_TASKS,
|
||||
type AiTaskGenerationOptions,
|
||||
} from "@/lib/ai-task-generation";
|
||||
import { mergeJournalHintsIntoLabels } from "@/lib/ai-journal-hints";
|
||||
|
||||
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
|
||||
@@ -27,6 +35,7 @@ function extractJsonArray(content: string) {
|
||||
|
||||
export async function generateAiTaskLabels(
|
||||
context: AiTaskContext,
|
||||
options?: AiTaskGenerationOptions,
|
||||
): Promise<string[]> {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
|
||||
@@ -34,6 +43,7 @@ export async function generateAiTaskLabels(
|
||||
throw new Error("OPENROUTER_API_KEY is not configured.");
|
||||
}
|
||||
|
||||
const maxCount = options?.maxCount ?? MAX_DAILY_AI_TASKS;
|
||||
const siteUrl =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
||||
|
||||
@@ -52,13 +62,13 @@ export async function generateAiTaskLabels(
|
||||
{
|
||||
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.",
|
||||
"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', single-word activities like 'Running', or duplicate tasks for the same activity. Never 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), include a closely related check-off task when appropriate. Return ONLY a JSON array of 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.`,
|
||||
${buildGenerationUserPrompt(context, options)}`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -84,11 +94,14 @@ Generate today's personalized habit task labels as a JSON array of strings.`,
|
||||
const labels = mergeJournalHintsIntoLabels(
|
||||
sanitizePlausibleAiTaskLabels(extractJsonArray(content) ?? []),
|
||||
context,
|
||||
{ fillInOnly: true, maxCount, excludeLabels: options?.excludeLabels },
|
||||
);
|
||||
|
||||
if (labels.length === 0) {
|
||||
const finalized = finalizeNewAiTaskLabels(labels, options);
|
||||
|
||||
if (finalized.length === 0 && maxCount > 0) {
|
||||
throw new Error("OpenRouter response did not include valid task labels.");
|
||||
}
|
||||
|
||||
return labels;
|
||||
return finalized;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user