diff --git a/web/client/src/api/standards.js b/web/client/src/api/standards.js index c0876c0..232e374 100644 --- a/web/client/src/api/standards.js +++ b/web/client/src/api/standards.js @@ -1,14 +1,21 @@ const BASE = "/api"; -// Safe fetch: always reads body as text first, then parses JSON. -// If the server returns HTML (e.g. 404 from a stale process or proxy miss), -// this surfaces a clear error instead of "Unexpected token '<'". +/** + * Safely fetches JSON from the server, handling edge cases where the server + * may return HTML/plain text (e.g., proxy errors, stale process 404s). + * @param {string} url - The endpoint URL. + * @param {RequestInit} [options={}] - Fetch options. + * @returns {Promise} Parsed JSON response. + * @throws {Error} On network failure or non-OK responses. + */ async function safeFetch(url, options = {}) { let res; try { res = await fetch(url, options); } catch (networkErr) { - throw new Error(`Network error — is the server running? (${networkErr.message})`); + const err = new Error(`Network error — is the server running? (${networkErr.message})`); + err.cause = networkErr; + throw err; } const text = await res.text(); @@ -33,23 +40,52 @@ async function safeFetch(url, options = {}) { return data; } +/** + * Fetches a paginated list of standards with optional filtering. + * @param {Object} [params={}] - Query parameters. + * @param {string} [params.q=""] - Search query. + * @param {string} [params.category=""] - Category filter. + * @param {number} [params.page=1] - Page number. + * @param {number} [params.limit=18] - Results per page. + * @returns {{data: Object[], meta: {total: number, page: number, limit: number, totalPages: number}}} + */ export async function fetchStandards({ q = "", category = "", page = 1, limit = 18 } = {}) { const params = new URLSearchParams({ q, category, page, limit }); return safeFetch(`${BASE}/standards?${params}`); } +/** + * Fetches a single standard by its IS identifier. + * @param {string} id - The standard IS ID (e.g., "IS 269"). + * @returns {Promise} The standard object. + */ export async function fetchStandard(id) { return safeFetch(`${BASE}/standards/${encodeURIComponent(id)}`); } +/** + * Fetches all material categories. + * @returns {Promise>} + */ export async function fetchCategories() { return safeFetch(`${BASE}/categories`); } +/** + * Fetches portal statistics. + * @returns {{totalStandards: number, totalCategories: number, totalChunks: number}} + */ export async function fetchStats() { return safeFetch(`${BASE}/stats`); } +/** + * Asks a conversational question about a specific standard. + * @param {Object} params - Parameters. + * @param {string} params.standard_id - The standard IS ID. + * @param {string} params.question - The question. + * @returns {{answer: string}} + */ export async function askQuestion({ standard_id, question }) { return safeFetch(`${BASE}/chat`, { method: "POST", @@ -58,7 +94,15 @@ export async function askQuestion({ standard_id, question }) { }); } -// POST /api/recommend — hybrid retrieval + LLM explanations +/** + * Hybrid retrieval with AI explanations. + * Uses FAISS + BM25 for retrieval, then Groq LLM for explanations. + * @param {Object} [params={}] - Query parameters. + * @param {string} params.query - Natural language query. + * @param {number} [params.top_n=5] - Number of results. + * @param {boolean} [params.rewrite=false] - Whether to rewrite the query with AI. + * @returns {{query: string, standards: Object[], latency: {retrieval_ms: number, llm_ms: number, total_ms: number}}} + */ export async function recommend({ query, top_n = 5, rewrite = false } = {}) { return safeFetch(`${BASE}/recommend`, { method: "POST", @@ -67,7 +111,13 @@ export async function recommend({ query, top_n = 5, rewrite = false } = {}) { }); } -// POST /api/ask — chunk-grounded QA for a specific standard +/** + * Chunk-grounded QA for a specific standard. + * @param {Object} [params={}] - Parameters. + * @param {string} params.standard_id - The standard IS ID. + * @param {string} params.question - The question. + * @returns {{answer: string, source: {standard_id: string, section: string, chunk_id: string}, latency: {llm_ms: number, total_ms: number}}} + */ export async function askGrounded({ standard_id, question } = {}) { return safeFetch(`${BASE}/ask`, { method: "POST", diff --git a/web/client/src/pages/Standards.jsx b/web/client/src/pages/Standards.jsx index 86cf1f2..f169adc 100644 --- a/web/client/src/pages/Standards.jsx +++ b/web/client/src/pages/Standards.jsx @@ -1,13 +1,17 @@ import { useEffect, useState, useCallback } from "react"; import { useSearchParams } from "react-router-dom"; import { fetchStandards, fetchCategories } from "../api/standards"; -import { useDebounce } from "../hooks/useDebounce"; import StandardCard from "../components/StandardCard"; import StandardModal from "../components/StandardModal"; import "./Standards.css"; const PAGE_SIZE = 18; +/** + * Standards search page with pagination. + * Uses URL search params for query/category state. + * Loads standards on mount and handles user interactions. + */ export default function Standards() { const [searchParams, setSearchParams] = useSearchParams(); @@ -22,12 +26,6 @@ export default function Standards() { const [error, setError] = useState(null); const [selected, setSelected] = useState(null); - const debouncedQuery = useDebounce(query, 300); - - useEffect(() => { - fetchCategories().then(setCategories).catch(() => {}); - }, []); - const load = useCallback(async (q, cat, pg) => { setLoading(true); setError(null); @@ -35,6 +33,7 @@ export default function Standards() { const data = await fetchStandards({ q, category: cat, page: pg, limit: PAGE_SIZE }); setResults(data.data); setMeta(data.meta); + setPage(pg); } catch { setError("Could not load standards. Is the server running?"); } finally { @@ -43,20 +42,34 @@ export default function Standards() { }, []); useEffect(() => { - setPage(1); + fetchCategories().then(setCategories).catch(() => {}); + load(query, category, 1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { const params = {}; - if (debouncedQuery) params.q = debouncedQuery; + if (query) params.q = query; if (category) params.category = category; setSearchParams(params, { replace: true }); - load(debouncedQuery, category, 1); - }, [debouncedQuery, category, load, setSearchParams]); + }, [query, category, setSearchParams]); + + const handleCategoryChange = (value) => { + setCategory(value); + load(query, value, 1); + }; const handlePageChange = (pg) => { - setPage(pg); - load(debouncedQuery, category, pg); + load(query, category, pg); window.scrollTo({ top: 0, behavior: "smooth" }); }; + const handleClearSearch = () => { + setQuery(""); + setCategory(""); + load("", "", 1); + }; + return (
@@ -64,11 +77,7 @@ export default function Standards() {

Find an IS Standard

Search by standard number, title, material, or keyword.

-
e.preventDefault()} - > + e.preventDefault()}>
setQuery("")} + onClick={handleClearSearch} aria-label="Clear search" > ✕ @@ -94,7 +103,7 @@ export default function Standards() {