Skip to content

Commit 0a66126

Browse files
committed
feat(chat): add search_content tool with hybrid semantic search and query healing
1 parent 4b68f80 commit 0a66126

1 file changed

Lines changed: 136 additions & 5 deletions

File tree

‎apps/site/src/pages/api/chat.ts‎

Lines changed: 136 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { env } from "cloudflare:workers";
22
import { createMCPClient } from "@ai-sdk/mcp";
33
import { createClient } from "@sanity/client";
4-
import { isStepCount, streamText } from "ai";
4+
import { isStepCount, jsonSchema, streamText, tool } from "ai";
55
import type { APIRoute } from "astro";
66
import { createWorkersAI } from "workers-ai-provider";
7+
import {
8+
semanticSearchQuery,
9+
textSearchFallbackQuery,
10+
} from "@/lib/sanity/queries";
711

812
export const prerender = false;
913

@@ -139,6 +143,81 @@ export const POST: APIRoute = async ({ request }) => {
139143

140144
const tools = {
141145
...mcpTools,
146+
search_content: tool({
147+
description:
148+
"Search CodingCat.dev articles, podcast episodes, authors, and transcripts using hybrid semantic search and keyword ranking. ALWAYS use this tool first when asked to find, recommend, or query podcasts, articles, or topics (e.g. 'Sanity', 'Firebase', 'Next.js', 'AI', 'web dev').",
149+
inputSchema: jsonSchema<{
150+
query: string;
151+
type?: "all" | "podcast" | "post" | "author" | "guest";
152+
}>({
153+
type: "object",
154+
properties: {
155+
query: {
156+
type: "string",
157+
description:
158+
"Search terms, topic, or question (e.g. 'Sanity', 'SvelteKit', 'AI SaaS').",
159+
},
160+
type: {
161+
type: "string",
162+
description:
163+
"Optional content type filter: 'all', 'podcast', 'post', 'author', 'guest'. Defaults to 'all'.",
164+
enum: ["all", "podcast", "post", "author", "guest"],
165+
},
166+
},
167+
required: ["query"],
168+
}),
169+
execute: async ({ query, type }) => {
170+
const filterType = type && type !== "all" ? type : null;
171+
try {
172+
const results = await sanityClient.fetch(semanticSearchQuery, {
173+
searchTerm: query,
174+
type: filterType,
175+
});
176+
if (Array.isArray(results) && results.length > 0) {
177+
return {
178+
content: [
179+
{
180+
type: "text",
181+
text: JSON.stringify({ results }),
182+
},
183+
],
184+
};
185+
}
186+
} catch (semanticErr) {
187+
console.warn("[search_content semantic error, falling back]", semanticErr);
188+
}
189+
190+
try {
191+
const fallback = await sanityClient.fetch(textSearchFallbackQuery, {
192+
searchTerm: query,
193+
type: filterType,
194+
});
195+
return {
196+
content: [
197+
{
198+
type: "text",
199+
text: JSON.stringify({ results: fallback || [] }),
200+
},
201+
],
202+
};
203+
} catch (fallbackErr) {
204+
return {
205+
content: [
206+
{
207+
type: "text",
208+
text: JSON.stringify({
209+
error:
210+
fallbackErr instanceof Error
211+
? fallbackErr.message
212+
: String(fallbackErr),
213+
}),
214+
},
215+
],
216+
isError: true,
217+
};
218+
}
219+
},
220+
}),
142221
groq_query: {
143222
...mcpTools.groq_query,
144223
execute: async ({ query }: { query: string }) => {
@@ -161,15 +240,62 @@ export const POST: APIRoute = async ({ request }) => {
161240
}
162241
} catch {}
163242

164-
// Fallback: direct Sanity fetch
243+
// Fallback: direct Sanity fetch with query healing
165244
try {
166-
const cleanQuery = query.startsWith("*") ? query : `*[${query}]`;
245+
let cleanQuery = query.startsWith("*") ? query : `*[${query}]`;
246+
// Heal common LLM query syntax issues:
247+
// Heal [a, b] match "..." -> (a match "..." || b match "...")
248+
cleanQuery = cleanQuery.replace(
249+
/\[([a-zA-Z0-9_]+),\s*([a-zA-Z0-9_]+)\]\s+match\s+([^)]+\))/g,
250+
"($1 match $3 || $2 match $3)",
251+
);
252+
// Heal text::query("...") -> "*...*"
253+
cleanQuery = cleanQuery.replace(
254+
/text::query\((["'])(.*?)\1\)/g,
255+
"$1*$2*$1",
256+
);
257+
// Remove filter [_score > 0]
258+
cleanQuery = cleanQuery.replace(/\[_score\s*>\s*0\]/g, "");
259+
167260
const directResult = await sanityClient.fetch(cleanQuery);
261+
if (Array.isArray(directResult) && directResult.length > 0) {
262+
return {
263+
content: [
264+
{
265+
type: "text",
266+
text: JSON.stringify({ result: directResult }),
267+
},
268+
],
269+
};
270+
}
271+
272+
// If still empty and query was a search for a keyword, extract keyword and run semantic search
273+
const matchTerm = query.match(/match\s+["']\*?([a-zA-Z0-9_-]+)\*?["']/);
274+
if (matchTerm?.[1]) {
275+
const keyword = matchTerm[1];
276+
const typeMatch = query.match(/_type\s*==\s*["']([a-zA-Z0-9_-]+)["']/);
277+
const filterType = typeMatch?.[1] || null;
278+
const semanticResults = await sanityClient.fetch(semanticSearchQuery, {
279+
searchTerm: keyword,
280+
type: filterType,
281+
});
282+
if (Array.isArray(semanticResults) && semanticResults.length > 0) {
283+
return {
284+
content: [
285+
{
286+
type: "text",
287+
text: JSON.stringify({ result: semanticResults }),
288+
},
289+
],
290+
};
291+
}
292+
}
293+
168294
return {
169295
content: [
170296
{
171297
type: "text",
172-
text: JSON.stringify({ result: directResult }),
298+
text: JSON.stringify({ result: directResult || [] }),
173299
},
174300
],
175301
};
@@ -219,7 +345,12 @@ export const POST: APIRoute = async ({ request }) => {
219345
const result = streamText({
220346
model,
221347
system:
222-
"You are CodingCat.dev's intelligent content assistant (AJ). Answer user queries by searching and exploring the Sanity Content Lake dataset using your tools (initial_context, groq_query, schema_explorer, array_field_reader).\n\nDataset tips:\n- Content types: 'post' (blog articles), 'podcast' (podcast episodes), 'author', 'guest', 'transcript'.\n- People's names (authors, guests) are stored in the 'title' field (e.g. *[_type == 'author' && title match '*Alex*']).\n- Fields on posts and podcasts: title, slug, excerpt, date, author[]->title, guest[]->title, youtube.\n- Once you receive results from your query, synthesize them into a helpful, conversational, grounded response citing specific titles and URLs (/post/<slug> or /podcast/<slug>).",
348+
"You are CodingCat.dev's intelligent content assistant (AJ). Answer user queries about CodingCat.dev podcasts, tutorials, articles, authors, and transcripts.\n\n" +
349+
"SEARCH & TOOL INSTRUCTIONS:\n" +
350+
"- ALWAYS use the `search_content` tool when asked to find, recommend, or query podcasts, posts, or articles on any topic, technology, or question (e.g. 'Sanity', 'Firebase', 'Next.js', 'AI'). It performs hybrid semantic and keyword search across all episodes, posts, and transcripts.\n" +
351+
"- Use `groq_query` for precise lookups when searching for specific author profiles (e.g. *[_type == 'author' && title match '*Alex*']).\n" +
352+
"- CRITICAL: ALWAYS execute tools by invoking them via function calls. NEVER output raw JSON or code blocks in your text describing tool calls instead of executing them.\n" +
353+
"- Once you receive results from your tool calls, synthesize them into an engaging, helpful response. List the relevant episode or article titles, brief descriptions, and their links (/podcast/<slug> or /post/<slug>).",
223354
messages: coreMessages,
224355
tools,
225356
stopWhen: isStepCount(5),

0 commit comments

Comments
 (0)