119 lines
4.2 KiB
TypeScript
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) });
|
|
}
|
|
});
|