Skip to content

Commit 5100930

Browse files
authored
Merge pull request #10 from benthecoder/clustering-viz
Clustering viz
2 parents d272768 + 1cfb0a0 commit 5100930

File tree

10 files changed

+1606
-458
lines changed

10 files changed

+1606
-458
lines changed

app/api/embeddings/route.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { sql } from "@vercel/postgres";
2+
import { NextResponse } from "next/server";
3+
import { computeUMAP, normalizePositions } from "@/utils/umapUtils";
4+
5+
interface ChunkRow {
6+
id: string;
7+
post_slug: string;
8+
post_title: string;
9+
content: string;
10+
chunk_type: string;
11+
metadata: {
12+
published_date?: string;
13+
tags?: string[];
14+
};
15+
sequence: number;
16+
embedding: unknown;
17+
created_at: string;
18+
}
19+
20+
interface ArticleData {
21+
id: string;
22+
postSlug: string;
23+
postTitle: string;
24+
content: string;
25+
chunkType: string;
26+
metadata: ChunkRow["metadata"];
27+
sequence: number;
28+
embedding: number[];
29+
publishedDate?: string;
30+
tags: string[];
31+
createdAt: string;
32+
index: number;
33+
x: number;
34+
y: number;
35+
}
36+
37+
const parseEmbedding = (embedding: unknown): number[] => {
38+
if (Array.isArray(embedding)) {
39+
return embedding;
40+
}
41+
42+
if (typeof embedding === "string") {
43+
try {
44+
const parsed = JSON.parse(embedding);
45+
if (Array.isArray(parsed)) {
46+
return parsed;
47+
}
48+
} catch {
49+
// Parse PostgreSQL vector format
50+
const cleaned = embedding.replace(/[\[\]]/g, "");
51+
return cleaned.split(",").map(Number);
52+
}
53+
}
54+
55+
return [];
56+
};
57+
58+
export async function GET(request: Request) {
59+
try {
60+
const { searchParams } = new URL(request.url);
61+
const nNeighbors = parseInt(searchParams.get("neighbors") || "8");
62+
const minDist = parseFloat(searchParams.get("minDist") || "0.05");
63+
const spread = parseFloat(searchParams.get("spread") || "2.0");
64+
65+
const results = await sql<ChunkRow>`
66+
SELECT DISTINCT ON (post_slug)
67+
id,
68+
post_slug,
69+
post_title,
70+
content,
71+
chunk_type,
72+
metadata,
73+
sequence,
74+
embedding,
75+
created_at
76+
FROM content_chunks
77+
WHERE embedding IS NOT NULL
78+
ORDER BY
79+
post_slug,
80+
CASE WHEN chunk_type = 'full-post' THEN 0 ELSE 1 END,
81+
sequence
82+
`;
83+
84+
const parsedData = results.rows
85+
.map((row, index) => ({
86+
id: row.id,
87+
postSlug: row.post_slug,
88+
postTitle: row.post_title,
89+
content: row.content,
90+
chunkType: row.chunk_type,
91+
metadata: row.metadata,
92+
sequence: row.sequence,
93+
embedding: parseEmbedding(row.embedding),
94+
publishedDate: row.metadata?.published_date,
95+
tags: row.metadata?.tags || [],
96+
createdAt: row.created_at,
97+
index,
98+
}))
99+
.filter((item) => item.embedding.length > 0);
100+
101+
const embeddings = parsedData.map((item) => item.embedding);
102+
const umapPositions = computeUMAP(embeddings, {
103+
nNeighbors: Math.min(nNeighbors, parsedData.length - 1),
104+
minDist,
105+
spread,
106+
});
107+
108+
const normalizedPositions = normalizePositions(
109+
umapPositions,
110+
1000,
111+
1000,
112+
50
113+
);
114+
115+
const processedData: ArticleData[] = parsedData.map((item, index) => ({
116+
...item,
117+
x: normalizedPositions[index].x,
118+
y: normalizedPositions[index].y,
119+
}));
120+
121+
return NextResponse.json({
122+
success: true,
123+
data: processedData,
124+
count: processedData.length,
125+
});
126+
} catch (error) {
127+
console.error("Error fetching embeddings:", error);
128+
return NextResponse.json(
129+
{
130+
error: "Failed to fetch embeddings data",
131+
details: error instanceof Error ? error.message : "Unknown error",
132+
},
133+
{ status: 500 }
134+
);
135+
}
136+
}

app/posts/page.tsx

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,25 +49,21 @@ const ArchivePage = async ({
4949
</Link>
5050
</div>
5151
<div className="text-black-400"></div>
52-
<div>
53-
<Link
54-
href="/random"
55-
className="underline hover:text-light-accent dark:hover:text-dark-accent transition-colors"
56-
>
57-
random
58-
</Link>{" "}
59-
🎲
60-
</div>
61-
<div className="text-black-400"></div>
6252
<div>
6353
<Link
6454
href="/search"
6555
className="underline hover:text-light-accent dark:hover:text-dark-accent transition-colors"
6656
>
6757
search
6858
</Link>{" "}
69-
🔍
7059
</div>
60+
<div className="text-black-400"></div>
61+
<Link
62+
href="/viz"
63+
className="underline hover:text-light-accent dark:hover:text-dark-accent transition-colors"
64+
>
65+
viz
66+
</Link>
7167
</div>
7268

7369
<p className="text-japanese-sumiiro dark:text-japanese-murasakisuishiyou text-sm mb-4 font-medium">

app/search/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ function SearchContent() {
305305
hasSearched &&
306306
query && (
307307
<div className="text-center py-6 text-light-text/70 dark:text-dark-text/70">
308-
<p>No results found for "{query}"</p>
308+
<p>No results found for &quot;{query}&quot;</p>
309309
<p className="text-sm mt-2">
310310
Try a different search type or modify your query
311311
</p>

app/viz/page.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import KnowledgeMap from "../../components/KnowledgeMap";
2+
3+
export const metadata = {
4+
title: "knowledge map",
5+
description: "semantic relationships across my writing",
6+
};
7+
8+
export default function VisualizationPage() {
9+
return (
10+
<div>
11+
<h1 className="font-bold text-left mb-6 text-2xl hover:text-light-accent dark:hover:text-dark-accent transition-colors">
12+
knowledge map
13+
</h1>
14+
15+
<div className="h-[75vh] rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 shadow-sm">
16+
<KnowledgeMap className="w-full h-full" />
17+
</div>
18+
</div>
19+
);
20+
}

0 commit comments

Comments
 (0)