- Add continuous guided light-body-scan meditation (16 segments, 97% coverage) - Edge TTS API endpoint with Xiaoxiao neural voice at -40% rate - Pre-generated chime WAVs and guidance MP3s in public/audio/ - Mute toggle in practice page, sequenced non-overlapping audio - Fix middleware to serve /audio and /api/audio/tts without auth - All LLM output localized to Chinese; fix blank home/settings pages - DeepSeek API compat: json_object, increased max_tokens - Analytics, cron reminders, onboarding flow fixes Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
212 lines
6.7 KiB
TypeScript
212 lines
6.7 KiB
TypeScript
import OpenAI from "openai";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { keywordCheck, llmSafetyCheck, type SafetyResult } from "@/lib/safety";
|
|
import type { Lang } from "@/lib/i18n/types";
|
|
import { createServerT } from "@/lib/i18n/utils";
|
|
import zh from "../../../dictionaries/zh";
|
|
import en from "../../../dictionaries/en";
|
|
|
|
const DICTS = { zh, en } as const;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface WeekData {
|
|
sessions: {
|
|
taskType: string;
|
|
taskDescription: string;
|
|
focusScore: number | null;
|
|
emotionScore: number | null;
|
|
notes: string | null;
|
|
durationSeconds: number;
|
|
createdAt: Date;
|
|
}[];
|
|
state: {
|
|
attentionLevel: number;
|
|
emotionStability: number;
|
|
stressLevel: number;
|
|
practiceStage: number;
|
|
consistencyScore: number;
|
|
streakDays: number;
|
|
totalSessionsCompleted: number;
|
|
};
|
|
}
|
|
|
|
interface GeneratedSummary {
|
|
summary: string;
|
|
safety: SafetyResult;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LLM summary generation (LLM #3)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SUMMARY_PROMPT = `You are a wise, warm inner development coach writing a weekly practice summary. Your tone is grounded, encouraging, and specific — never generic or saccharine.
|
|
|
|
You will receive data about the user's practice this week: sessions completed, task types, focus/emotion scores, reflections, and their current state metrics (attention, emotional stability, stress, consistency, streak, stage).
|
|
|
|
Write a 3-4 paragraph summary that:
|
|
|
|
1. Opens by acknowledging their commitment this week — be specific about what they practiced and how many sessions
|
|
2. Highlights patterns you notice: which types of practice led to better focus? Did their emotional awareness deepen? Were there difficult days they pushed through?
|
|
3. Notes changes in their state metrics from what you can infer — are they trending in a good direction?
|
|
4. Closes with gentle encouragement for the week ahead, grounded in what they've actually demonstrated (not empty motivation)
|
|
|
|
Voice rules:
|
|
- Be specific. Reference actual task types and patterns. "Your two attention training sessions showed stronger focus than your stress regulation work" is good. "Great job this week!" is not.
|
|
- Be honest. If it was a light week with only 1-2 sessions, acknowledge that without judgment.
|
|
- Never diagnose, never claim medical benefits, never use self-help clichés.
|
|
- Write in second person ("you").
|
|
- Keep it to 3-4 paragraphs, 200-300 words total.
|
|
|
|
IMPORTANT: The user interface is Chinese-only. Write the entire summary in Simplified Chinese (简体中文). Use "你" (not "you").`;
|
|
|
|
export async function generateWeeklySummary(
|
|
userId: string,
|
|
openai: OpenAI,
|
|
locale: Lang = "zh"
|
|
): Promise<GeneratedSummary> {
|
|
const t = createServerT(DICTS[locale]);
|
|
|
|
// Gather week data
|
|
const weekAgo = new Date();
|
|
weekAgo.setDate(weekAgo.getDate() - 7);
|
|
|
|
const [sessions, userState] = await Promise.all([
|
|
prisma.session.findMany({
|
|
where: {
|
|
userId,
|
|
createdAt: { gte: weekAgo },
|
|
completed: true,
|
|
},
|
|
orderBy: { createdAt: "asc" },
|
|
select: {
|
|
taskType: true,
|
|
taskDescription: true,
|
|
focusScore: true,
|
|
emotionScore: true,
|
|
notes: true,
|
|
durationSeconds: true,
|
|
createdAt: true,
|
|
},
|
|
}),
|
|
prisma.userState.findUnique({ where: { userId } }),
|
|
]);
|
|
|
|
if (!userState || sessions.length === 0) {
|
|
return {
|
|
summary: t("engine.summary.no_sessions"),
|
|
safety: { safe: true },
|
|
};
|
|
}
|
|
|
|
const weekData: WeekData = {
|
|
sessions,
|
|
state: {
|
|
attentionLevel: userState.attentionLevel,
|
|
emotionStability: userState.emotionStability,
|
|
stressLevel: userState.stressLevel,
|
|
practiceStage: userState.practiceStage,
|
|
consistencyScore: userState.consistencyScore,
|
|
streakDays: userState.streakDays,
|
|
totalSessionsCompleted: userState.totalSessionsCompleted,
|
|
},
|
|
};
|
|
|
|
try {
|
|
const response = await openai.chat.completions.create({
|
|
model: "deepseek-v4-pro",
|
|
messages: [
|
|
{ role: "system", content: SUMMARY_PROMPT },
|
|
{
|
|
role: "user",
|
|
content: JSON.stringify(weekData),
|
|
},
|
|
],
|
|
temperature: 0.7,
|
|
max_tokens: 2000,
|
|
});
|
|
|
|
const summary = response.choices[0]?.message?.content?.trim();
|
|
if (!summary || summary.length < 50) throw new Error("Summary too short");
|
|
|
|
// Safety check
|
|
const keywordResult = keywordCheck(summary);
|
|
if (!keywordResult.safe) {
|
|
return { summary: t("engine.summary.safety_fallback"), safety: keywordResult };
|
|
}
|
|
|
|
const llmSafety = await llmSafetyCheck(summary, openai as any);
|
|
|
|
// Persist the summary (upsert — one per user per week)
|
|
const weekStart = new Date(weekAgo);
|
|
weekStart.setHours(0, 0, 0, 0);
|
|
|
|
const safetyFallback = t("engine.summary.safety_fallback");
|
|
|
|
await prisma.weeklySummary.upsert({
|
|
where: { userId },
|
|
create: {
|
|
userId,
|
|
summary: llmSafety.safe ? summary : safetyFallback,
|
|
weekStart,
|
|
},
|
|
update: {
|
|
summary: llmSafety.safe ? summary : safetyFallback,
|
|
weekStart,
|
|
},
|
|
});
|
|
|
|
return {
|
|
summary: llmSafety.safe ? summary : safetyFallback,
|
|
safety: llmSafety,
|
|
};
|
|
} catch {
|
|
const count = sessions.length;
|
|
const streak = userState.streakDays;
|
|
const fallback = t("engine.summary.fallback_template", {
|
|
count,
|
|
plural: count === 1 ? "" : "s",
|
|
streak,
|
|
streak_plural: streak === 1 ? "" : "s",
|
|
});
|
|
return { summary: fallback, safety: { safe: true } };
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cron handler — generates summaries for all active users
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function generateAllWeeklySummaries(
|
|
openai: OpenAI,
|
|
locale: Lang = "zh"
|
|
): Promise<{ userId: string; success: boolean }[]> {
|
|
// Find users who had sessions in the last 7 days
|
|
const weekAgo = new Date();
|
|
weekAgo.setDate(weekAgo.getDate() - 7);
|
|
|
|
const activeUsers = await prisma.session.findMany({
|
|
where: {
|
|
createdAt: { gte: weekAgo },
|
|
completed: true,
|
|
},
|
|
select: { userId: true },
|
|
distinct: ["userId"],
|
|
});
|
|
|
|
const results: { userId: string; success: boolean }[] = [];
|
|
|
|
for (const { userId } of activeUsers) {
|
|
try {
|
|
await generateWeeklySummary(userId, openai, locale);
|
|
results.push({ userId, success: true });
|
|
} catch {
|
|
results.push({ userId, success: false });
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|