import { Router, Request, Response } from "express"; import { pool } from "../db/client"; import { semanticSearch } from "../embeddings/client"; export const kbRouter = Router(); // GET /api/kb — Knowledge base browser: FAQ + troubleshooting entries // ?q=search&category=faq|troubleshooting|known_issue&limit=50&semantic=1 // Falls back to Qdrant semantic search when ILIKE returns 0 results kbRouter.get("/", async (req: Request, res: Response) => { const q = ((req.query.q as string) || "").trim(); const category = (req.query.category as string) || ""; const limit = Math.min(parseInt((req.query.limit as string) || "60"), 200); const forceSemantic = req.query.semantic === "1"; try { const [textEntries, cats] = await Promise.all([ pool.query( `SELECT id, category, subcategory, question, answer, applies_to_form_factors, applies_to_speeds, severity, tags FROM knowledge_base WHERE ($1 = '' OR category = $1) AND ($2 = '' OR question ILIKE '%' || $2 || '%' OR answer ILIKE '%' || $2 || '%' OR subcategory ILIKE '%' || $2 || '%') ORDER BY CASE WHEN $2 != '' AND question ILIKE '%' || $2 || '%' THEN 0 ELSE 1 END, category, subcategory, id LIMIT $3`, [category, q, limit] ), pool.query( `SELECT category, COUNT(*)::int AS count FROM knowledge_base GROUP BY category ORDER BY count DESC` ), ]); // If text search found results and semantic not forced, return them if (textEntries.rows.length > 0 && !forceSemantic) { return res.json({ success: true, entries: textEntries.rows, categories: cats.rows, total: textEntries.rows.length, query: q, search_mode: "text", }); } // Semantic fallback — only when query is provided and text search returned nothing if (q.length > 2) { try { const collections: Array<"faq_embeddings" | "troubleshooting_embeddings"> = category === "faq" ? ["faq_embeddings"] : category === "troubleshooting" ? ["troubleshooting_embeddings"] : ["faq_embeddings", "troubleshooting_embeddings"]; const semanticHits = ( await Promise.all( collections.map(col => semanticSearch(col, q, Math.ceil(limit / collections.length)) .catch(() => [] as Array<{ id: string; score: number; payload: Record }>) ) ) ).flat().sort((a, b) => b.score - a.score); // Deduplicate by kb id from payload, then fetch full rows from DB const kbIds = [...new Set( semanticHits .filter(h => h.score >= 0.5 && h.payload.kb_id) .slice(0, limit) .map(h => h.payload.kb_id as string) )]; if (kbIds.length > 0) { const semanticRows = await pool.query( `SELECT id, category, subcategory, question, answer, applies_to_form_factors, applies_to_speeds, severity, tags FROM knowledge_base WHERE id = ANY($1::int[])`, [kbIds.map(Number).filter(n => !isNaN(n))] ); // Sort results by semantic score order const scoreMap = new Map(semanticHits.map(h => [String(h.payload.kb_id), h.score])); const sorted = semanticRows.rows.sort( (a, b) => (scoreMap.get(String(b.id)) || 0) - (scoreMap.get(String(a.id)) || 0) ); return res.json({ success: true, entries: sorted, categories: cats.rows, total: sorted.length, query: q, search_mode: "semantic", }); } } catch (_semErr) { // Semantic search unavailable — fall through to text results } } // Final fallback: return text results (even if empty) return res.json({ success: true, entries: textEntries.rows, categories: cats.rows, total: textEntries.rows.length, query: q, search_mode: "text", }); } catch (err) { res.status(500).json({ success: false, error: String(err) }); } });