disclosure-bureau/web/app/api/search/hybrid/route.ts
Luiz Gustavo eaf282c535
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 40s
CI / Scripts — Python smoke (push) Failing after 3s
CI / Web — npm audit (push) Failing after 29s
CI / Retrieval — golden set (Recall@5 + MRR) (push) Failing after 3s
W2: rerank opt-in, analyze_image_region tool, RAG eval, graph cleanup, ADRs
- TD#8 hybrid.ts: rerank_strategy {always|when_top_k_gt|never} + threshold
  (default skips rerank for top_k ≤ 15; chat tool uses threshold 10)
- O11 vision.ts + tools.ts: analyze_image_region tool — sharp-crops the
  bbox, claude CLI reads the temp PNG via Read tool, Sonnet vision answers
- TD#12 /graph: SigmaGraph replaces ForceGraphCanvas; react-force-graph-2d
  uninstalled (-37 transitive deps); force-graph-canvas.tsx deleted
- TD#27 messages/route.ts gatherContext slice sizes via CTX_* env vars
- TD#22 tests/rag/: golden.yaml (15 queries) + run.py (Recall@k + MRR +
  negative-pass rate) + baseline.json + CI job in .forgejo/workflows/ci.yml
- docs/adrs/: ADR-001..005 published from systems-atelier deliverables

Verified live on disclosure.top: top_k=5 path skips rerank (6.7s embed-only,
was 12-15s with rerank); rerank=always still available on demand.
First RAG baseline: Recall@5 = 0.2083, MRR = 0.25, Negative pass = 1.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:20:09 -03:00

70 lines
2.5 KiB
TypeScript

/**
* /api/search/hybrid — Public hybrid search endpoint (no auth required).
*
* Used by the global Cmd+K command palette + any external integration.
* Returns the same shape as the chat tool but exposed via HTTP.
*
* GET /api/search/hybrid?q=...&lang=pt&doc_id=...&top_k=10
*/
import { NextRequest } from "next/server";
import { hybridSearch } from "@/lib/retrieval/hybrid";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function json(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json" },
});
}
export async function GET(req: NextRequest) {
const u = new URL(req.url);
const q = u.searchParams.get("q")?.trim();
if (!q) return json({ error: "q required", hits: [] }, 400);
const lang = (u.searchParams.get("lang") === "en" ? "en" : "pt") as "pt" | "en";
const doc_id = u.searchParams.get("doc_id") || null;
const type = u.searchParams.get("type") || null;
const top_k = Math.min(Number(u.searchParams.get("top_k") ?? 10), 50);
const ufo_only = u.searchParams.get("ufo_only") === "1";
// W2-TD#8: rerank now has three modes. Back-compat: `rerank=0` keeps the
// old "never" shortcut. New: `rerank=always|when_top_k_gt|never` and
// `rerank_threshold=N` (default 15). Default strategy is `when_top_k_gt`.
const rerankParam = u.searchParams.get("rerank");
const no_rerank = rerankParam === "0";
const rerank_strategy = (
rerankParam === "always" || rerankParam === "never" || rerankParam === "when_top_k_gt"
? rerankParam
: "when_top_k_gt"
) as "always" | "when_top_k_gt" | "never";
const rerank_threshold = Math.max(
1,
Math.min(50, Number(u.searchParams.get("rerank_threshold") ?? 15)),
);
try {
const hits = await hybridSearch({
query: q, lang, doc_id, type, ufo_only, top_k, no_rerank,
rerank_strategy, rerank_threshold,
});
return json({
query: q,
lang,
count: hits.length,
hits: hits.map((h) => ({
chunk_id: h.chunk_id,
doc_id: h.doc_id,
page: h.page,
type: h.type,
bbox: h.bbox,
classification: h.classification,
snippet: ((lang === "en" ? h.content_en : h.content_pt) || "").slice(0, 280),
score: Number((h.rerank_score ?? h.score).toFixed(4)),
href: `/d/${h.doc_id}#${h.chunk_id}`,
})),
});
} catch (e) {
return json({ error: "retrieval_unavailable", message: (e as Error).message }, 503);
}
}