Organize/lib/actions/summarize.ts

319 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use server";
import { addUTCDays, getWeekStart, startOfUTCDate, toDateKey } from "@/lib/dates";
import { callChatCompletion } from "@/lib/ai/chat-completion";
import { prisma } from "@/lib/db";
import { requireUserId } from "@/lib/auth-helpers";
import { SummaryRequestSchema, type SummaryRequest } from "@/lib/validation/summary";
export type BoardSummaryResult =
| { status: "ok"; content: string; rangeLabel: string; count: number }
| { status: "empty"; rangeLabel: string }
| { status: "error"; error: string };
// Hard prompt-size caps -- the same philosophy as lib/ai/context.ts: the
// whole relevant dataset goes straight into the prompt, truncated only if
// a huge custom range would blow the model's context window.
const DETAILS_CHAR_CAP = 300;
const TOTAL_CHAR_CAP = 40_000;
interface ResolvedRange {
// Half-open UTC interval: [from, to).
from: Date;
to: Date;
// Human label ("Aug 12, 2026", "This week: Aug 10 Aug 16, 2026", ...)
// shown to the user and quoted in the AI prompt.
label: string;
}
function formatUtcDay(date: Date, weekday: boolean): string {
return date.toLocaleDateString(
undefined,
{
...(weekday ? { weekday: "long" as const } : {}),
month: "short",
day: "numeric",
year: "numeric",
// UTC because the boundaries were computed in UTC -- rendering in a
// different timezone could shift an edge day.
timeZone: "UTC",
}
);
}
function monthLabel(year: number, monthIndex: number): string {
return new Date(Date.UTC(year, monthIndex, 1)).toLocaleDateString(undefined, {
month: "long",
year: "numeric",
timeZone: "UTC",
});
}
/** All boundaries are UTC calendar days -- same convention as lib/dates.ts. */
function resolveRange(request: SummaryRequest, now: Date): ResolvedRange {
const year = now.getUTCFullYear();
const month = now.getUTCMonth(); // 0-based
switch (request.dateRange) {
case "today": {
const from = startOfUTCDate(now);
return { from, to: addUTCDays(from, 1), label: formatUtcDay(from, true) };
}
case "thisWeek": {
const from = getWeekStart(now);
const to = addUTCDays(from, 7);
return { from, to, label: `This week (${formatUtcDay(from, false)} ${formatUtcDay(addUTCDays(to, -1), false)})` };
}
case "thisMonth": {
const from = new Date(Date.UTC(year, month, 1));
const to = new Date(Date.UTC(year, month + 1, 1));
return { from, to, label: `This month (${monthLabel(year, month)})` };
}
case "lastMonth": {
const from = new Date(Date.UTC(year, month - 1, 1));
const to = new Date(Date.UTC(year, month, 1));
return { from, to, label: `Last month (${monthLabel(year, month - 1)})` };
}
case "custom": {
// safeParse already guaranteed both dates exist when dateRange is "custom".
const from = startOfUTCDate(new Date(`${request.customStart}T00:00:00Z`));
const to = addUTCDays(startOfUTCDate(new Date(`${request.customEnd}T00:00:00Z`)), 1);
const end = addUTCDays(to, -1);
return { from, to, label: formatUtcDay(from, false) === formatUtcDay(end, false) ? formatUtcDay(from, true) : `${formatUtcDay(from, false)} ${formatUtcDay(end, false)}` };
}
}
}
interface BoardTodoEntry {
title: string;
details: string | null;
completedAt: Date;
groupTitle: string;
categoryName: string;
}
interface ScheduledEntry {
title: string;
occurrenceDate: Date;
completedAt: Date;
recurring: boolean;
}
interface BoardSummaryData {
categories: {
name: string;
groups: { title: string; archived: boolean; todos: BoardTodoEntry[] }[];
}[];
scheduled: ScheduledEntry[];
}
function formatDetails(details: string | null): string {
const trimmed = details?.trim();
if (!trimmed) return "";
if (trimmed.length <= DETAILS_CHAR_CAP) return `${trimmed}`;
return `${trimmed.slice(0, DETAILS_CHAR_CAP)}… [truncated]`;
}
/** Groups everything by the day it belongs to, in the order the owner asked for. */
function formatByDate(data: BoardSummaryData): string {
const lines: string[] = [];
const boardFlat: (BoardTodoEntry & { category: string; group: string })[] =
data.categories.flatMap((category) =>
category.groups.flatMap((group) =>
group.todos.map((todo) => ({ ...todo, category: category.name, group: group.title }))
)
);
if (boardFlat.length > 0) {
lines.push("Board to-dos, grouped by the day they were completed:");
const byDay = new Map<string, typeof boardFlat>();
for (const todo of boardFlat) {
const key = toDateKey(todo.completedAt);
const bucket = byDay.get(key);
if (bucket) bucket.push(todo);
else byDay.set(key, [todo]);
}
// Day keys are "YYYY-MM-DD", so lexicographic = chronological -- the
// model should see (and keep) oldest day first even though the
// underlying data is grouped by category/group.
for (const [key, todos] of [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b))) {
lines.push(`### ${formatUtcDay(new Date(`${key}T00:00:00Z`), true)}`);
for (const todo of todos) {
lines.push(`- ${todo.title} (group: ${todo.group}; category: ${todo.category})${formatDetails(todo.details)}`);
}
}
}
if (data.scheduled.length > 0) {
lines.push("Scheduled to-dos marked done in this range:");
for (const s of data.scheduled) {
lines.push(
`- ${s.title} (occurrence ${formatUtcDay(s.occurrenceDate, false)}${s.recurring ? ", recurring" : ""}; marked done ${formatUtcDay(s.completedAt, false)})`
);
}
}
return lines.join("\n");
}
function formatByCategory(data: BoardSummaryData): string {
const lines: string[] = [];
for (const category of data.categories) {
const withTodos = category.groups.filter((g) => g.todos.length > 0);
if (withTodos.length === 0) continue;
lines.push(`## Category: "${category.name}"`);
for (const group of withTodos) {
lines.push(`### Group: "${group.title}"${group.archived ? " [archived]" : ""}`);
for (const todo of group.todos) {
lines.push(`- ${todo.title} (completed ${formatUtcDay(todo.completedAt, false)})${formatDetails(todo.details)}`);
}
}
}
if (data.scheduled.length > 0) {
lines.push("## Scheduled to-dos");
for (const s of data.scheduled) {
lines.push(
`- ${s.title} (occurrence ${formatUtcDay(s.occurrenceDate, false)}${s.recurring ? ", recurring" : ""}; marked done ${formatUtcDay(s.completedAt, false)})`
);
}
}
return lines.join("\n");
}
/**
* AI summary of everything the user completed on one board (Home or a
* Project) inside a date range, organized the way they asked (by day or
* by category/group). "Completed" covers both kinds of to-do the app has:
* board to-dos whose `completedAt` falls in the range, and scheduled
* to-do occurrences marked done in the range. Read-only -- nothing here
* is ever saved, so there's no ask/finalize JSON contract, just a plain
* markdown answer from the model (see lib/ai/chat-completion.ts).
*/
export async function summarizeBoard(
projectId: string | null,
request: SummaryRequest
): Promise<BoardSummaryResult> {
const userId = await requireUserId();
const parsed = SummaryRequestSchema.safeParse(request);
if (!parsed.success) {
return { status: "error", error: parsed.error.issues[0]?.message ?? "Invalid request." };
}
const range = resolveRange(parsed.data, new Date());
// Scoped to exactly this board: Home means the user's personal
// categories; a Project means that project's categories, and only if
// this user owns it.
const scopeWhere = projectId
? { projectId, project: { ownerId: userId } }
: { userId, projectId: null };
const [categories, scheduledCompletions] = await Promise.all([
prisma.category.findMany({
where: scopeWhere,
orderBy: { order: "asc" },
// Archived groups are deliberately *not* filtered out (unlike
// lib/board.ts): this is a history question, and work completed
// before a group was archived should still count.
include: {
groups: {
orderBy: { order: "asc" },
include: {
todos: {
where: { completed: true, completedAt: { gte: range.from, lt: range.to } },
orderBy: { completedAt: "asc" },
},
},
},
},
}),
prisma.scheduledTodoCompletion.findMany({
where: {
completedAt: { gte: range.from, lt: range.to },
scheduledTodo: scopeWhere,
},
include: { scheduledTodo: { select: { title: true, rrule: true } } },
orderBy: { completedAt: "asc" },
}),
]);
const data: BoardSummaryData = {
categories: categories
.map((category) => ({
name: category.name,
groups: category.groups.map((group) => ({
title: group.title,
archived: group.archivedAt !== null,
todos: group.todos.map((todo) => ({
title: todo.title,
details: todo.details,
completedAt: todo.completedAt as Date,
groupTitle: group.title,
categoryName: category.name,
})),
})),
}))
.filter((category) => category.groups.some((group) => group.todos.length > 0)),
scheduled: scheduledCompletions.map((c) => ({
title: c.scheduledTodo.title,
occurrenceDate: c.occurrenceDate,
completedAt: c.completedAt,
recurring: c.scheduledTodo.rrule !== null,
})),
};
const count =
data.categories.reduce((n, c) => n + c.groups.reduce((m, g) => m + g.todos.length, 0), 0) +
data.scheduled.length;
// Skip the AI call entirely when there's nothing to summarize -- a
// clear, instant answer beats a model inventing one.
if (count === 0) return { status: "empty", rangeLabel: range.label };
let dataText = parsed.data.organizeBy === "byDate" ? formatByDate(data) : formatByCategory(data);
if (dataText.length > TOTAL_CHAR_CAP) {
dataText = `${dataText.slice(0, TOTAL_CHAR_CAP)}\n\n[... additional items omitted for length ...]`;
}
const organizeInstruction =
parsed.data.organizeBy === "byDate"
? 'organize it by day -- keep one section per day, in the same order the data is grouped, with each day as a "## " heading'
: 'organize it by category and group -- one "## " section per category and, inside it, a "### " sub-heading (or bold lead-in) per group';
const systemPrompt = [
"You are summarizing completed work from a to-do organizer app, for the person who owns the data.",
"",
`Date range being summarized: ${range.label}`,
"",
"Everything completed in that range is listed below, already grouped the way the owner wants the summary organized:",
"",
dataText,
"",
"Write a concise, warm markdown summary of what they got done.",
"- Output ONLY the markdown summary itself -- no preamble, no closing remarks, no code fences around it.",
`- ${organizeInstruction}. If scheduled to-dos are present, keep them in their own final section.`,
"- Open with one or two sentences recapping the overall amount of work in the range.",
"- Render completed items as short bullet lines; merge exact duplicates into a single line.",
"- Keep the whole summary tight (roughly 30 lines or fewer).",
].join("\n");
const result = await callChatCompletion(
[
{ role: "system", content: systemPrompt },
{ role: "user", content: "Summarize it now." },
],
// This is the app's biggest prompt (a whole range of completed work),
// and the configured provider may be a slow reasoning model -- give it
// room to finish rather than the 30s default.
{ temperature: 0.3, timeoutMs: 120_000 }
);
if ("error" in result) return { status: "error", error: result.error };
return { status: "ok", content: result.content, rangeLabel: range.label, count };
}