Organize/lib/ai/context.ts

91 lines
3.4 KiB
TypeScript

import "server-only";
import type { AiGroupContext } from "@/types/ai";
// This app is single-user/self-hosted scale (see lib/access.ts's comments
// on Projects being single-owner for now) -- there's no retrieval/ranking
// here, the whole relevant dataset just goes straight into the prompt,
// truncated only by the hard caps below. If a board ever grows large
// enough for this to blow the model's context window, the fix is a
// RAG-style retrieval step, not a bigger cap.
const NOTE_CHAR_CAP = 4_000;
const DETAILS_CHAR_CAP = 300;
const TOTAL_CHAR_CAP = 60_000;
function truncate(text: string, max: number): string {
const trimmed = text.trim();
if (trimmed.length <= max) return trimmed;
return `${trimmed.slice(0, max)}… [truncated]`;
}
function formatTodoLine(todo: {
title: string;
details: string | null;
completed: boolean;
completedAt: string | null;
}): string {
const box = todo.completed ? "[x]" : "[ ]";
const doneNote = todo.completed && todo.completedAt ? ` (done ${todo.completedAt.slice(0, 10)})` : "";
const details = todo.details?.trim() ? `${truncate(todo.details, DETAILS_CHAR_CAP)}` : "";
return `- ${box} ${todo.title}${doneNote}${details}`;
}
/** Renders one group's notes + todos as compact markdown-ish prompt text. */
export function formatGroupForPrompt(
group: {
title: string;
noteContent: string;
todos: { title: string; details: string | null; completed: boolean; completedAt: string | null }[];
},
opts?: { archived?: boolean }
): string {
const openCount = group.todos.filter((t) => !t.completed).length;
const doneCount = group.todos.length - openCount;
const notes = group.noteContent.trim()
? truncate(group.noteContent, NOTE_CHAR_CAP)
: "(no notes)";
const todoLines = group.todos.length > 0
? group.todos.map(formatTodoLine).join("\n")
: "(no to-dos)";
const archivedTag = opts?.archived ? " [archived]" : "";
return `Group${archivedTag}: "${group.title}"\n\nNotes:\n${notes}\n\nTo-dos (${openCount} open, ${doneCount} done):\n${todoLines}`;
}
/**
* Renders every group the user can see, grouped by board (Home/Project)
* then category, for the global chat's system prompt. Archived groups
* are marked inline rather than omitted, so historical questions stay
* answerable.
*/
export function formatAllGroupsForPrompt(groups: AiGroupContext[]): string {
const byScope = new Map<string, Map<string, AiGroupContext[]>>();
for (const group of groups) {
if (!byScope.has(group.scopeLabel)) byScope.set(group.scopeLabel, new Map());
const byCategory = byScope.get(group.scopeLabel)!;
if (!byCategory.has(group.categoryName)) byCategory.set(group.categoryName, []);
byCategory.get(group.categoryName)!.push(group);
}
const sections: string[] = [];
for (const [scopeLabel, byCategory] of byScope) {
sections.push(`# ${scopeLabel}`);
for (const [categoryName, categoryGroups] of byCategory) {
sections.push(`## Category: ${categoryName}`);
for (const group of categoryGroups) {
const formatted = formatGroupForPrompt(
{ title: group.groupTitle, noteContent: group.noteContent, todos: group.todos },
{ archived: group.archived }
);
sections.push(`### ${formatted}`);
}
}
}
let result = sections.join("\n\n");
if (result.length > TOTAL_CHAR_CAP) {
result = `${result.slice(0, TOTAL_CHAR_CAP)}\n\n[... additional groups omitted for length ...]`;
}
return result;
}