119 lines
4.2 KiB
TypeScript

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<string, unknown> }>)
)
)
).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) });
}
});