/** * /api/search/autocomplete — typo-tolerant prefix search via Meilisearch. * * Hits two indexes in parallel and returns a small merged result: * - documents (title-level matches, used to jump to a doc) * - chunks (passage-level matches, used for in-doc navigation) * * Target latency: sub-30ms inside the docker network. Falls back to empty * results if Meilisearch is unreachable so the chat / hybrid_search aren't * blocked. Auth: none — same as /api/search/hybrid; corpus is public. */ import { NextResponse } from "next/server"; import { withRequest } from "@/lib/logger"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const MEILI_URL = process.env.MEILISEARCH_URL || "http://meilisearch:7700"; const MEILI_KEY = process.env.MEILISEARCH_API_KEY || process.env.MEILI_MASTER_KEY || ""; interface DocHit { doc_id: string; canonical_title: string; collection?: string; } interface ChunkHit { chunk_pk: number; doc_id: string; chunk_id: string; page: number; type: string; content_pt?: string; content_en?: string; ufo_anomaly?: boolean; } async function meiliSearch(index: string, q: string, limit: number): Promise { const r = await fetch(`${MEILI_URL}/indexes/${index}/search`, { method: "POST", headers: { "Authorization": `Bearer ${MEILI_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ q, limit, attributesToHighlight: ["canonical_title", "content_pt", "content_en"] }), signal: AbortSignal.timeout(2000), }); if (!r.ok) throw new Error(`meili ${r.status}`); const data = await r.json(); return data.hits ?? []; } export async function GET(request: Request) { const log = withRequest(request); const url = new URL(request.url); const q = (url.searchParams.get("q") || "").trim(); const limit = Math.min(Number(url.searchParams.get("limit") || 8), 20); if (q.length < 2) { return NextResponse.json({ q, documents: [], chunks: [] }); } if (!MEILI_KEY) { log.warn({ event: "autocomplete_unconfigured" }, "MEILI key not set"); return NextResponse.json({ q, documents: [], chunks: [], reason: "meili_not_configured" }); } const t0 = Date.now(); const [docs, chunks] = await Promise.all([ meiliSearch("documents", q, Math.min(limit, 5)).catch(() => []), meiliSearch("chunks", q, limit).catch(() => []), ]) as [DocHit[], ChunkHit[]]; const dt = Date.now() - t0; log.info({ event: "autocomplete", q, docs: docs.length, chunks: chunks.length, dt_ms: dt }, "autocomplete done"); return NextResponse.json({ q, duration_ms: dt, documents: docs.map((d) => ({ doc_id: d.doc_id, title: d.canonical_title, collection: d.collection, href: `/d/${d.doc_id}`, })), chunks: chunks.map((c) => ({ chunk_id: c.chunk_id, doc_id: c.doc_id, page: c.page, type: c.type, excerpt: (c.content_pt || c.content_en || "").slice(0, 180), ufo_anomaly: !!c.ufo_anomaly, href: `/d/${c.doc_id}/p${String(c.page).padStart(3, "0")}#${c.chunk_id}`, })), }); }