W0 — security hardening (5 fixes verified live on disclosure.top)
- middleware: gate /api/admin/* same as /admin/* (F1)
- imgproxy: tighten LOCAL_FILESYSTEM_ROOT from / to /var/lib/storage (F2)
- studio: real basic-auth label (bcrypt hash, middleware reference) (F3)
- relations: ENABLE ROW LEVEL SECURITY + public SELECT policy (F4)
- migration 0003: fold is_searchable + hybrid_search update into canonical (TD#2)
W1 — observability + resilience + autocomplete
- studio: HOSTNAME=0.0.0.0 so Next.js binds on loopback for healthcheck
- compose: PG_POOL_MAX=20, CLAUDE_CODE_OAUTH_TOKEN gated by separate env
- claude-code.ts: subprocess timeout configurable (CLAUDE_CODE_TIMEOUT_MS)
- openrouter.ts: retry with exponential backoff + Retry-After + in-memory
circuit breaker (promotes FALLBACK after CB_THRESHOLD failures)
- lib/logger.ts: pino logger (NDJSON prod / pretty dev) + withRequest helper
- middleware: mints correlation_id, stamps x-correlation-id response header,
emits structured http_request log per /api/* call
- messages/route.ts: switch to structured logger
- 60_meili_index.py: push documents + chunks into Meilisearch
- /api/search/autocomplete: parallel meili search (docs + chunks), 5-8ms p50
- search-autocomplete.tsx: debounced dropdown wired into search-panel
W1.2 — Glitchtip + Forgejo self-hosted
- compose: glitchtip-redis + glitchtip-web + glitchtip-worker (v4.2)
- compose: forgejo + forgejo-runner (server v9, runner v6) with group_add=988
- @sentry/nextjs SDK wired (instrumentation.ts + sentry.{client,server}.config.ts)
- /api/admin/throw smoke endpoint (gated by W0-F1 middleware)
- Synthetic event ingestion verified at glitchtip.disclosure.top
- forgejo.disclosure.top up, repo discadmin/disclosure-bureau created,
runner registered (labels: ubuntu-latest, docker)
- .forgejo/workflows/ci.yml: typecheck + lint + build + npm audit + python
syntax + compose validation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
235 lines
8.2 KiB
TypeScript
235 lines
8.2 KiB
TypeScript
/**
|
|
* SearchPanel — full hybrid_search page with form controls and rich result cards.
|
|
*
|
|
* Syncs URL params so results are shareable. Click a result to open its chunk
|
|
* anchor in the V2 view.
|
|
*/
|
|
"use client";
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { useEffect, useState } from "react";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { SearchAutocomplete } from "./search-autocomplete";
|
|
|
|
interface Hit {
|
|
chunk_id: string;
|
|
doc_id: string;
|
|
page: number;
|
|
type: string;
|
|
bbox: { x: number; y: number; w: number; h: number } | null;
|
|
classification: string | null;
|
|
snippet: string;
|
|
score: number;
|
|
href: string;
|
|
}
|
|
|
|
export function SearchPanel({
|
|
initialQ,
|
|
initialLang,
|
|
initialType,
|
|
initialDocId,
|
|
}: {
|
|
initialQ: string;
|
|
initialLang: "pt" | "en";
|
|
initialType: string;
|
|
initialDocId: string;
|
|
}) {
|
|
const router = useRouter();
|
|
const params = useSearchParams();
|
|
const [q, setQ] = useState(initialQ);
|
|
const [lang, setLang] = useState<"pt" | "en">(initialLang);
|
|
const [type, setType] = useState(initialType);
|
|
const [docId, setDocId] = useState(initialDocId);
|
|
const [hits, setHits] = useState<Hit[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Initial fetch if URL had params
|
|
useEffect(() => {
|
|
if (initialQ) doSearch(initialQ, initialLang, initialType, initialDocId);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
async function doSearch(qStr: string, l: "pt" | "en", t: string, d: string) {
|
|
if (!qStr.trim()) return;
|
|
setLoading(true);
|
|
setError(null);
|
|
const sp = new URLSearchParams({ q: qStr, lang: l, top_k: "25" });
|
|
if (t) sp.set("type", t);
|
|
if (d) sp.set("doc_id", d);
|
|
try {
|
|
const r = await fetch(`/api/search/hybrid?${sp}`);
|
|
if (!r.ok) {
|
|
const j = await r.json().catch(() => ({}));
|
|
setError(j.message ?? `HTTP ${r.status}`);
|
|
setHits([]);
|
|
return;
|
|
}
|
|
const j = (await r.json()) as { hits?: Hit[] };
|
|
setHits(j.hits ?? []);
|
|
} catch (e) {
|
|
setError((e as Error).message);
|
|
setHits([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
function submit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
// Sync URL (shareable)
|
|
const sp = new URLSearchParams(params.toString());
|
|
sp.set("q", q);
|
|
sp.set("lang", lang);
|
|
if (type) sp.set("type", type);
|
|
else sp.delete("type");
|
|
if (docId) sp.set("doc_id", docId);
|
|
else sp.delete("doc_id");
|
|
router.replace(`/search?${sp}`);
|
|
doSearch(q, lang, type, docId);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<form
|
|
onSubmit={submit}
|
|
className="space-y-3 mb-8 p-4 border border-[rgba(0,255,156,0.15)] bg-[#0a121e] rounded"
|
|
>
|
|
<div className="relative">
|
|
<label className="font-mono text-[10px] uppercase tracking-widest text-[#5a6678] block mb-1">
|
|
query
|
|
</label>
|
|
<input
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
placeholder="ex. objetos esféricos avistados em Kansas, MJ-12, Roswell..."
|
|
className="w-full bg-transparent border border-[rgba(0,255,156,0.20)] focus:border-[#00ff9c] rounded px-3 py-2 font-mono text-sm text-[#c8d4e6] outline-none"
|
|
autoFocus
|
|
/>
|
|
<SearchAutocomplete query={q} onPick={() => setQ("")} />
|
|
</div>
|
|
<div className="flex flex-wrap items-end gap-3">
|
|
<div>
|
|
<label className="font-mono text-[10px] uppercase tracking-widest text-[#5a6678] block mb-1">
|
|
idioma
|
|
</label>
|
|
<div className="flex gap-1">
|
|
{(["pt", "en"] as const).map((l) => (
|
|
<button
|
|
key={l}
|
|
type="button"
|
|
onClick={() => setLang(l)}
|
|
className={`px-3 py-1 font-mono text-xs rounded border ${
|
|
lang === l
|
|
? "border-[#00ff9c] text-[#00ff9c] bg-[rgba(0,255,156,0.10)]"
|
|
: "border-[rgba(127,219,255,0.20)] text-[#8896aa]"
|
|
}`}
|
|
>
|
|
{l}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="font-mono text-[10px] uppercase tracking-widest text-[#5a6678] block mb-1">
|
|
chunk type
|
|
</label>
|
|
<select
|
|
value={type}
|
|
onChange={(e) => setType(e.target.value)}
|
|
className="bg-[#0a121e] border border-[rgba(0,255,156,0.20)] focus:border-[#00ff9c] rounded px-2 py-1 font-mono text-xs text-[#c8d4e6] outline-none"
|
|
>
|
|
<option value="">qualquer</option>
|
|
{[
|
|
"paragraph",
|
|
"heading",
|
|
"image",
|
|
"stamp",
|
|
"signature",
|
|
"table_marker",
|
|
"address_block",
|
|
"form_field",
|
|
"classification_marking",
|
|
"redaction",
|
|
].map((t) => (
|
|
<option key={t} value={t}>
|
|
{t}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="flex-1 min-w-[180px]">
|
|
<label className="font-mono text-[10px] uppercase tracking-widest text-[#5a6678] block mb-1">
|
|
filtrar doc
|
|
</label>
|
|
<input
|
|
value={docId}
|
|
onChange={(e) => setDocId(e.target.value)}
|
|
placeholder="opcional: doc-id exato"
|
|
className="w-full bg-transparent border border-[rgba(0,255,156,0.20)] focus:border-[#00ff9c] rounded px-2 py-1 font-mono text-xs text-[#c8d4e6] outline-none"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={!q.trim() || loading}
|
|
className="px-4 py-1.5 font-mono text-xs uppercase tracking-widest border border-[#00ff9c] text-[#00ff9c] bg-[rgba(0,255,156,0.10)] hover:bg-[rgba(0,255,156,0.20)] rounded disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
{loading ? "buscando…" : "buscar"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
{error && (
|
|
<div className="mb-6 p-3 border border-[rgba(255,107,107,0.30)] bg-[rgba(255,107,107,0.05)] rounded font-mono text-xs text-[#ff6b6b]">
|
|
retrieval indisponível: {error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
{hits.map((h) => {
|
|
const cropUrl = h.bbox
|
|
? `/api/crop?doc=${encodeURIComponent(h.doc_id)}&page=${h.page}` +
|
|
`&x=${h.bbox.x}&y=${h.bbox.y}&w=${h.bbox.w}&h=${h.bbox.h}&w_px=320`
|
|
: null;
|
|
return (
|
|
<Link
|
|
key={`${h.doc_id}-${h.chunk_id}`}
|
|
href={h.href}
|
|
className="block p-3 bg-[#0a121e] border border-[rgba(127,219,255,0.20)] hover:border-[#00ff9c] rounded transition flex gap-4"
|
|
>
|
|
{cropUrl && (
|
|
<Image
|
|
src={cropUrl}
|
|
alt=""
|
|
width={160}
|
|
height={100}
|
|
className="block w-40 h-24 object-cover bg-black rounded flex-shrink-0"
|
|
unoptimized
|
|
/>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 text-[10px] font-mono mb-1">
|
|
<span className="text-[#00ff9c] font-bold">{h.chunk_id}</span>
|
|
<span className="text-[#5a6678]">p{h.page}</span>
|
|
<span className="text-[#5a6678]">{h.type}</span>
|
|
{h.classification && (
|
|
<span className="text-[#ff6b6b]">{h.classification}</span>
|
|
)}
|
|
<span className="ml-auto text-[#8896aa]">{h.score.toFixed(3)}</span>
|
|
</div>
|
|
<p className="text-[#c8d4e6] text-sm line-clamp-3 mb-1">{h.snippet}</p>
|
|
<div className="text-[10px] font-mono text-[#5a6678] truncate">{h.doc_id}</div>
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{!loading && !error && initialQ && hits.length === 0 && (
|
|
<div className="text-center text-[#5a6678] font-mono text-sm py-12">
|
|
nenhum resultado
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|