Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions apps/zettel/src/notes/graph-extraction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from "vitest";
import { extractAndReplaceNoteGraph, extractNoteGraph } from "./graph-extraction.js";

describe("note graph extraction", () => {
it("normalizes valid provider output into NoteGraphInput", async () => {
const provider = {
extractGraph: vi.fn().mockResolvedValue({
entities: [
{ name: " Ada ", type: " person ", description: " mathematician " },
{ name: " Engine ", type: " machine ", description: "" },
],
relations: [{ source: " Ada ", target: " Engine ", relationship: " designed " }],
}),
};

await expect(extractNoteGraph(provider, "tenant-1", "note-1", "note text")).resolves.toEqual({
entities: [
{ name: "Ada", type: "person", description: "mathematician" },
{ name: "Engine", type: "machine", description: "" },
],
relations: [{ source: "Ada", target: "Engine", relationship: "designed" }],
});
});

it("rejects malformed provider output", async () => {
const provider = { extractGraph: vi.fn().mockResolvedValue({ entities: "Ada", relations: [] }) };

await expect(extractNoteGraph(provider, "tenant-1", "note-1", "text")).rejects.toThrow();
});

it("rejects relations whose normalized endpoints are absent", async () => {
const provider = {
extractGraph: vi.fn().mockResolvedValue({
entities: [{ name: "Ada", type: "person", description: "" }],
relations: [{ source: "Ada", target: "Engine", relationship: "designed" }],
}),
};

await expect(extractNoteGraph(provider, "tenant-1", "note-1", "text")).rejects.toThrow(
"relation endpoint",
);
});

it("forwards tenant, note, and text to the provider and persistence boundary", async () => {
const graph = { entities: [], relations: [] };
const provider = { extractGraph: vi.fn().mockResolvedValue(graph) };
const replaceGraph = vi.fn().mockResolvedValue(undefined);

await extractAndReplaceNoteGraph("tenant-7", "note-9", "full note", provider, replaceGraph);

expect(provider.extractGraph).toHaveBeenCalledWith({
userId: "tenant-7",
noteId: "note-9",
text: "full note",
});
expect(replaceGraph).toHaveBeenCalledWith("tenant-7", "note-9", graph);
});

it("does not persist when extraction fails", async () => {
const provider = { extractGraph: vi.fn().mockRejectedValue(new Error("model unavailable")) };
const replaceGraph = vi.fn();

await expect(
extractAndReplaceNoteGraph("tenant-1", "note-1", "text", provider, replaceGraph),
).rejects.toThrow("model unavailable");
expect(replaceGraph).not.toHaveBeenCalled();
});
});
84 changes: 84 additions & 0 deletions apps/zettel/src/notes/graph-extraction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { z } from "zod";
import { replaceNoteGraph, type NoteGraphInput } from "./store.js";

const graphSchema = z
.object({
entities: z.array(
z
.object({
name: z.string().trim().min(1),
type: z.string().trim().min(1),
description: z.string().trim(),
})
.strict(),
),
relations: z.array(
z
.object({
source: z.string().trim().min(1),
target: z.string().trim().min(1),
relationship: z.string().trim().min(1),
})
.strict(),
),
})
.strict()
.superRefine((graph, context) => {
const entityNames = new Set<string>();
for (const entity of graph.entities) {
if (entityNames.has(entity.name)) {
context.addIssue({
code: "custom",
message: `duplicate entity: ${entity.name}`,
path: ["entities"],
});
}
entityNames.add(entity.name);
}

graph.relations.forEach((relation, index) => {
if (!entityNames.has(relation.source) || !entityNames.has(relation.target)) {
context.addIssue({
code: "custom",
message: "relation endpoint must be included in entities",
path: ["relations", index],
});
}
});
});

export interface GraphExtractionRequest {
userId: string;
noteId: string;
text: string;
}

/** Model-independent boundary. Implementations may call any structured-output provider. */
export interface GraphExtractionProvider {
extractGraph(request: GraphExtractionRequest): Promise<unknown>;
}

export async function extractNoteGraph(
provider: GraphExtractionProvider,
userId: string,
noteId: string,
text: string,
): Promise<NoteGraphInput> {
const output = await provider.extractGraph({ userId, noteId, text });
return graphSchema.parse(output);
}

type ReplaceGraph = typeof replaceNoteGraph;

/** Persist only a fully extracted and validated graph, preserving prior data on extraction failure. */
export async function extractAndReplaceNoteGraph(
userId: string,
noteId: string,
text: string,
provider: GraphExtractionProvider,
replaceGraph: ReplaceGraph = replaceNoteGraph,
): Promise<NoteGraphInput> {
const graph = await extractNoteGraph(provider, userId, noteId, text);
await replaceGraph(userId, noteId, graph);
return graph;
}
Loading