91 lines
3.4 KiB
TypeScript
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;
|
|
}
|