From d65ff89322a7ee57ea7d854748f4b96b65c4898f Mon Sep 17 00:00:00 2001 From: floory <67979730+flooryyyy@users.noreply.github.com> Date: Sat, 4 Apr 2026 13:59:35 +0100 Subject: [PATCH] feat: add ai context resolver utility with tests --- .../project/utils/aiContext.test.ts | 52 ++++++ src/app/components/project/utils/aiContext.ts | 162 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 src/app/components/project/utils/aiContext.test.ts create mode 100644 src/app/components/project/utils/aiContext.ts diff --git a/src/app/components/project/utils/aiContext.test.ts b/src/app/components/project/utils/aiContext.test.ts new file mode 100644 index 0000000..781c2a2 --- /dev/null +++ b/src/app/components/project/utils/aiContext.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { resolveAiContext } from "./aiContext"; + +describe("resolveAiContext", () => { + it("resolves selected blocks and linked blocks with stable traversal order", () => { + const result = resolveAiContext( + [ + { id: "a", data: { title: "Alpha", content: "A" } }, + { id: "b", data: { title: "Beta", content: "B" } }, + { id: "c", data: { title: "Charlie", content: "C" } }, + { id: "d", data: { title: "Delta", content: "D" } }, + { id: "e", type: "file", data: { title: "Echo", content: "E" } }, + ], + [ + { source: "a", target: "d" }, + { source: "c", target: "e" }, + { source: "a", target: "b" }, + { source: "c", target: "d" }, + { source: "b", target: "e" }, + ], + ["c", "a"], + { + maxChars: 1_000, + excludeIds: ["d"], + excludeTypes: ["file"], + }, + ); + + expect(result.orderedIds).toEqual(["c", "a", "b"]); + expect(result.text).toBe( + "# Charlie\n\nC\n\n# Alpha\n\nA\n\n# Beta\n\nB", + ); + expect(result.truncated).toBe(false); + }); + + it("truncates to the character budget and appends ellipsis for partial entries", () => { + const result = resolveAiContext( + [ + { id: "a", data: { content: "abcdefghij" } }, + { id: "b", data: { content: "klmnopqrst" } }, + ], + [], + ["a", "b"], + { maxChars: 18 }, + ); + + expect(result.orderedIds).toEqual(["a", "b"]); + expect(result.text).toBe("abcdefghij\n\nklmno…"); + expect(result.text.length).toBe(18); + expect(result.truncated).toBe(true); + }); +}); diff --git a/src/app/components/project/utils/aiContext.ts b/src/app/components/project/utils/aiContext.ts new file mode 100644 index 0000000..b09cbff --- /dev/null +++ b/src/app/components/project/utils/aiContext.ts @@ -0,0 +1,162 @@ +export type AiContextBlock = { + id: string; + type?: string | null; + data?: { + title?: string | null; + content?: string | null; + } | null; +}; + +export type AiContextLink = { + source: string; + target: string; +}; + +export type ResolveAiContextOptions = { + maxChars: number; + separator?: string; + excludeIds?: readonly string[]; + excludeTypes?: readonly string[]; +}; + +export type ResolvedAiContext = { + text: string; + orderedIds: string[]; + truncated: boolean; +}; + +const DEFAULT_SEPARATOR = "\n\n"; +const ELLIPSIS = "…"; + +const isBlank = (value?: string | null) => (value ?? "").trim().length === 0; + +const formatBlockContext = (block: AiContextBlock) => { + const title = block.data?.title?.trim() ?? ""; + const content = block.data?.content?.trim() ?? ""; + + if (title && content) { + return `# ${title}\n\n${content}`; + } + + if (title) { + return `# ${title}`; + } + + return content; +}; + +const truncateWithEllipsis = (value: string, maxChars: number) => { + if (maxChars <= 0) return ""; + if (value.length <= maxChars) return value; + if (maxChars === 1) return ELLIPSIS; + return `${value.slice(0, maxChars - 1)}${ELLIPSIS}`; +}; + +export const resolveAiContext = ( + blocks: readonly AiContextBlock[], + links: readonly AiContextLink[], + selectedIds: readonly string[], + { + maxChars, + separator = DEFAULT_SEPARATOR, + excludeIds = [], + excludeTypes = [], + }: ResolveAiContextOptions, +): ResolvedAiContext => { + if (maxChars <= 0) { + return { text: "", orderedIds: [], truncated: false }; + } + + const blocksById = new Map(blocks.map((block) => [block.id, block])); + const excludedIdSet = new Set(excludeIds); + const excludedTypeSet = new Set(excludeTypes); + + const adjacency = new Map(); + links.forEach((link) => { + const existing = adjacency.get(link.source) ?? []; + existing.push(link.target); + adjacency.set(link.source, existing); + }); + + adjacency.forEach((targets, source) => { + const uniqueTargets = Array.from(new Set(targets)).sort((a, b) => + a.localeCompare(b), + ); + adjacency.set(source, uniqueTargets); + }); + + const orderedIds: string[] = []; + const visited = new Set(); + const queued = new Set(); + const queue: string[] = []; + + selectedIds.forEach((id) => { + if (queued.has(id)) return; + queue.push(id); + queued.add(id); + }); + + while (queue.length > 0) { + const currentId = queue.shift(); + if (!currentId || visited.has(currentId)) continue; + + visited.add(currentId); + + const block = blocksById.get(currentId); + const hasContent = + !isBlank(block?.data?.title) || !isBlank(block?.data?.content); + const canInclude = + Boolean(block) && + !excludedIdSet.has(currentId) && + !excludedTypeSet.has(block?.type ?? "") && + hasContent; + + if (canInclude && block) { + orderedIds.push(currentId); + } + + const neighbors = adjacency.get(currentId) ?? []; + neighbors.forEach((neighborId) => { + if (visited.has(neighborId) || queued.has(neighborId)) return; + queue.push(neighborId); + queued.add(neighborId); + }); + } + + const entries = orderedIds + .map((id) => blocksById.get(id)) + .filter((block): block is AiContextBlock => Boolean(block)) + .map((block) => formatBlockContext(block)) + .filter((entry) => !isBlank(entry)); + + let text = ""; + let truncated = false; + + for (const entry of entries) { + if (!text) { + if (entry.length <= maxChars) { + text = entry; + } else { + text = truncateWithEllipsis(entry, maxChars); + truncated = true; + break; + } + continue; + } + + const combined = `${text}${separator}${entry}`; + if (combined.length <= maxChars) { + text = combined; + continue; + } + + const remaining = maxChars - text.length - separator.length; + if (remaining > 0) { + text = `${text}${separator}${truncateWithEllipsis(entry, remaining)}`; + } + truncated = true; + break; + } + + return { text, orderedIds, truncated }; +};