feat(search): add header global search (Phase A) — cases + precedents + docs
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 41s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 41s
Adds an always-visible debounced search input in the AppShell header that fans out to three independent sources in parallel and renders per-source result groups with their own loading/empty/error states: - /api/search/cases (NEW): SQL ILIKE on case_number, address, parties, title, subject. Returns small projections, no embeddings needed. - /api/precedent-library/search (existing): semantic over case-law halachot + passages. - /api/search (existing): semantic over case documents + past decisions. Cmd/Ctrl+K focuses the input; Esc and click-outside close the panel. This is Phase A of the header redesign — the bar layout itself is unchanged; row grouping + dynamic context follow in Phase B. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
113
web-ui/src/lib/api/global-search.ts
Normal file
113
web-ui/src/lib/api/global-search.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Global header search — fans out to three independent sources in parallel.
|
||||
*
|
||||
* Each source is its own `useQuery`, so the fastest one (cases, plain SQL)
|
||||
* shows up immediately while the slower vector searches stream in. A failure
|
||||
* in one source does not block the others — the result panel renders per-
|
||||
* source skeletons and per-source error states.
|
||||
*
|
||||
* Sources:
|
||||
* - cases → GET /api/search/cases (SQL ILIKE on case_number/address/parties)
|
||||
* - precedent → GET /api/precedent-library/search (semantic, halachot+chunks)
|
||||
* - documents → GET /api/search (semantic, all case docs + past decisions)
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
import type { SearchHit as PrecedentHit } from "./precedent-library";
|
||||
|
||||
export type CaseHit = {
|
||||
case_number: string;
|
||||
title: string;
|
||||
property_address: string | null;
|
||||
status: string | null;
|
||||
practice_area: string | null;
|
||||
appeal_subtype: string | null;
|
||||
};
|
||||
|
||||
export type DocumentHit = {
|
||||
score: number;
|
||||
case_number: string;
|
||||
document: string;
|
||||
section: string;
|
||||
page: number | null;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const MIN_QUERY_LEN = 2;
|
||||
const STALE_MS = 10_000;
|
||||
const PER_SOURCE_LIMIT = 5;
|
||||
|
||||
const enabled = (q: string) => q.trim().length >= MIN_QUERY_LEN;
|
||||
|
||||
export function useCasesSearch(query: string) {
|
||||
return useQuery({
|
||||
queryKey: ["global-search", "cases", query],
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<{ items: CaseHit[]; count: number }>(
|
||||
`/api/search/cases?q=${encodeURIComponent(query)}&limit=${PER_SOURCE_LIMIT}`,
|
||||
{ signal },
|
||||
),
|
||||
enabled: enabled(query),
|
||||
staleTime: STALE_MS,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePrecedentSearch(query: string) {
|
||||
return useQuery({
|
||||
queryKey: ["global-search", "precedent", query],
|
||||
queryFn: ({ signal }) => {
|
||||
const p = new URLSearchParams({
|
||||
q: query,
|
||||
limit: String(PER_SOURCE_LIMIT),
|
||||
include_halachot: "true",
|
||||
});
|
||||
return apiRequest<{ items: PrecedentHit[]; count: number }>(
|
||||
`/api/precedent-library/search?${p.toString()}`,
|
||||
{ signal },
|
||||
);
|
||||
},
|
||||
enabled: enabled(query),
|
||||
staleTime: STALE_MS,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The /api/search endpoint returns either an array of DocumentHit, or
|
||||
* `{message: "לא נמצאו תוצאות."}` when empty. Normalize to a plain array.
|
||||
*/
|
||||
export function useDocumentsSearch(query: string) {
|
||||
return useQuery({
|
||||
queryKey: ["global-search", "documents", query],
|
||||
queryFn: async ({ signal }) => {
|
||||
const raw = await apiRequest<DocumentHit[] | { message: string }>(
|
||||
`/api/search?query=${encodeURIComponent(query)}&limit=${PER_SOURCE_LIMIT}`,
|
||||
{ signal },
|
||||
);
|
||||
return Array.isArray(raw) ? raw : [];
|
||||
},
|
||||
enabled: enabled(query),
|
||||
staleTime: STALE_MS,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGlobalSearch(query: string) {
|
||||
const cases = useCasesSearch(query);
|
||||
const precedent = usePrecedentSearch(query);
|
||||
const documents = useDocumentsSearch(query);
|
||||
|
||||
const isQueryReady = enabled(query);
|
||||
const anyLoading =
|
||||
isQueryReady && (cases.isLoading || precedent.isLoading || documents.isLoading);
|
||||
|
||||
return {
|
||||
cases,
|
||||
precedent,
|
||||
documents,
|
||||
isQueryReady,
|
||||
anyLoading,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user