import type { FastifyPluginAsync, FastifyRequest } from 'fastify'; import { z } from 'zod'; import { prisma } from '../db.js'; import { checkRateLimit, rateLimitResponse } from '../lib/rate-limit.js'; function stripHtmlToMarkdown(html: string): string { if (!html) return ''; return html .replace(/]*>(.*?)<\/h[1-6]>/gi, '\n## $1\n') .replace(/]*>(.*?)<\/p>/gi, '\n$1\n') .replace(/]*href="([^"]+)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)') .replace(/]*>(.*?)<\/strong>/gi, '**$1**') .replace(/]*>(.*?)<\/em>/gi, '*$1*') .replace(/<[^>]+>/g, '') .replace(/\n\s*\n/g, '\n\n') .trim(); } export const ragRoutes: FastifyPluginAsync = async (app) => { app.get('/api/v1/rag/:slug', async (req, reply) => { const { slug } = req.params as { slug: string }; const article = await prisma.article.findUnique({ where: { slug }, select: { title: true, summary: true, content: true, dateModified: true } }); if (!article) return reply.code(404).send('# Not Found'); reply.header('Content-Type', 'text/markdown; charset=utf-8'); let markdown = `# ${article.title}\n\n`; if (article.summary) markdown += `> ${article.summary}\n\n`; markdown += `Last Modified: ${article.dateModified?.toISOString()}\n\n`; markdown += stripHtmlToMarkdown(article.content); return markdown; }); app.post('/api/v1/search/semantic', async (req: FastifyRequest, reply) => { const rl = await checkRateLimit(req.ip, 'semantic', 10, 60); if (rl.limited) return rateLimitResponse(reply, rl.retryAfter); const schema = z.object({ query: z.string().min(1), limit: z.number().max(20).optional() }); const parsed = schema.safeParse(req.body); if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' }); const { semanticSearch } = await import('../lib/embeddings.js'); const results = await semanticSearch(parsed.data.query, parsed.data.limit || 5); return { results }; }); };