KB Semantic-Search Frontend — Implementation Plan
For agentic workers: execute task-by-task via subagent-driven development. Each task ends with a runnable stdlib
unittestcheck or a stated live verification. NO git commits, NO code comments (house rules). Steps use- [ ].
Goal: Add server-side semantic search (existing Qdrant + bge-large) as the KB’s hero, served by the existing Flask app, replacing the slow client-side lunr search as the primary entry.
Architecture: Extend /home/levander/knowledgebase/app.py. Reuse kb-vectors/search.py + embed.py (bge-large singleton) + Qdrant manuals. New /api/search JSON route + a search-first home. Keep mkdocs static pages served by the existing catch-all.
Design spec: 2026-07-29-kb-semantic-search-frontend-design. Related: 2026-07-24-kb-vectorize-complete, telep-mainframe.
Global Constraints
- Work on telep-mainframe as
levander;/usr/bin/sshonly. NO git commit. NO code comments. One decl/line; no inline compound statements. - App dir
/home/levander/knowledgebase/(venv there; Flask+waitress @127.0.0.1:8092,Restart=alwayssystemdknowledgebase.service). Restart:sudo systemctl restart knowledgebase. - Semantic backend
/home/levander/kb-vectors/:search.search(client, query, limit=8, folder=None, manual=None) -> [payload+score];embed.embed_query(text)(lazy singleton_get_model(), bge-large CPU);index.COLLECTION="manuals"; QdrantQdrantClient(host="127.0.0.1", port=6333). Payload fields:folder, manual, manual_id, page, page_url, heading, images, text, chunk_index, score. - Tests: stdlib
unittest,cd /home/levander/knowledgebase && venv/bin/python -m unittest <module>. - NON-DESTRUCTIVE: don’t modify Qdrant, docs, the mkdocs
site/, orclusters.json. Back upapp.pybefore editing (.bak-search-fe). No new always-on service — everything runs inside the existing Flask process. - PSU limit: cap embedding threads (
torch.set_num_threads(4)/OMP_NUM_THREADS=4) so concurrent encodes don’t brown-out the box. - bge-large is English-only (manuals are English). HU UI chrome via existing
i18n.py.
Task 0: Search deps + import wiring (GO/NO-GO foundation)
Purpose: make the knowledgebase venv able to import kb-vectors/search.py (which imports embed → sentence_transformers/torch, and qdrant_client). If these can’t install cleanly, pivot to a tiny localhost search service (documented fallback) — decide here.
Files: none yet (env + a probe script /home/levander/knowledgebase/_probe_search.py, delete after).
Steps:
- Check what the knowledgebase venv already has:
venv/bin/python -c "import qdrant_client, sentence_transformers, torch; print('have all')"— if it errors, note which are missing. - Install the missing ones (CPU torch, to match kb-vectors):
venv/bin/pip install qdrant-client sentence-transformers(torch CPU comes as a dep; if it tries CUDA, pin the CPU wheel like kb-vectors uses — checkkb-vectors/venv/bin/pip freeze | grep -iE "torch|sentence|qdrant"and match those versions to avoid a heavy/incompatible pull). Prefer matching kb-vectors’ pinned versions. - Probe import from the app venv with kb-vectors on the path:
import sys
sys.path.insert(0, "/home/levander/kb-vectors")
import search, embed
from qdrant_client import QdrantClient
c = QdrantClient(host="127.0.0.1", port=6333)
hits = search.search(c, "fuel cut controller", limit=3)
print(len(hits), hits[0]["page_url"] if hits else "none")Run: venv/bin/python _probe_search.py → expect ≥1 hit with a page_url.
- GATE: if the probe returns hits → proceed (deps + import path work in-process). If install is infeasible/breaks the venv → STOP, restore, and switch the plan to the fallback (a small
search_service.pyrun under kb-vectors venv on 127.0.0.1:8car, proxied by the app) — report which path. - Record the working versions + the exact
sys.pathline needed. Delete_probe_search.py.
Task 1: searchui.py pure helpers
Files: Create /home/levander/knowledgebase/searchui.py, test_searchui.py.
Interfaces produced:
dedupe_by_page(hits) -> hits— keep best-score hit perpage_url, preserve order by score desc.make_snippet(text, query, width=220) -> str— window around the first query-term hit (fallback: head of text), collapsed whitespace.highlight(snippet, query) -> str— wrap query terms in<mark>…</mark>(case-insensitive, HTML-escape the rest).classify(manual_id, page_url) -> "article"|"wiring"|"manual"—consolidated/→ article; wiring manuals (wiring-diagrams,5door-supplement, or a page with a sibling.wires.txt) → wiring; else manual.vehicle_label(folder) -> str— prettify top folder (suzuki-vitara→“Suzuki Vitara”,chevy-tracker→“Chevy/Geo Tracker”,kick-fix→“kick-fix”, …).
Steps:
- Write
test_searchui.py: dedupe keeps the higher score per page + drops dupes; snippet centers on the query term and respects width; highlight wraps terms + escapes HTML (no XSS from text); classify maps the three cases; vehicle_label maps known folders + falls back. - Run
venv/bin/python -m unittest test_searchui→ FAIL (no module). - Implement
searchui.py; rerun → PASS.
Task 2: /api/search route + startup singletons
Files: Modify /home/levander/knowledgebase/app.py (back up .bak-search-fe). Test: test_api_search.py.
Interfaces produced: GET /api/search?q=&folder=&manual=&limit= → {took_ms, count, hits:[{heading, manual_id, vehicle, page_url, snippet, score, type}]}.
Steps:
- In
app.py:import sys; sys.path.insert(0, "/home/levander/kb-vectors")(from Task 0),import search as kbsearch, embed;import searchui; add module globalsQCLIENT = QdrantClient(host="127.0.0.1", port=6333)and a warmed embedder: inmain()beforeserve(...), callembed.embed_query("warmup")(loads the singleton) andtorch.set_num_threads(4)(guard import). - Add route:
@app.route("/api/search")
def api_search():
q = (request.args.get("q") or "").strip()
if not q:
return jsonify({"took_ms": 0, "count": 0, "hits": []})
folder = request.args.get("folder") or None
manual = request.args.get("manual") or None
limit = min(int(request.args.get("limit", 20)), 50)
import time
t0 = time.monotonic()
try:
raw = kbsearch.search(QCLIENT, q, limit=limit, folder=folder, manual=manual)
except Exception as error:
return jsonify({"error": str(error)[:200]}), 503
hits = searchui.dedupe_by_page(raw)
out = []
for h in hits:
out.append({
"heading": h.get("heading") or h.get("page"),
"manual_id": h.get("manual_id"),
"vehicle": searchui.vehicle_label(h.get("folder", "")),
"page_url": "/" + h.get("page_url", "").lstrip("/"),
"snippet": searchui.highlight(searchui.make_snippet(h.get("text", ""), q), q),
"score": round(h.get("score", 0.0), 3),
"type": searchui.classify(h.get("manual_id", ""), h.get("page_url", "")),
})
took = int((time.monotonic() - t0) * 1000)
return jsonify({"took_ms": took, "count": len(out), "hits": out})-
test_api_search.py: monkeypatchkbsearch.searchto return fake payloads → assert JSON shape, dedupe applied, emptyq→ empty hits, filters passed through, a raised error → 503. (Useapp.test_client(); stub so no real Qdrant/model needed.) - Run
venv/bin/python -m unittest test_api_search→ PASS.
Task 3: Search-first home UI + / override
Files: Modify app.py (add SEARCH_HTML, override /, add /browse). Update test_app.py if it asserts on /.
Steps:
- Add
SEARCH_HTML(inlinerender_template_string, dark theme matchingSEMANTICS_HTML): autofocused search box; JS that debounces (~200 ms) input →fetch("/api/search?q=...&folder=...")→ renders results (type badge, heading link topage_url— fortype=="wiring"append#kinyert-huzalozasi-adatok-ai, vehicle/manual, highlighted snippet, score); vehicle filter<select>; result count +took_ms; ↑/↓/Enter keyboard nav; i18n chrome viai18n.strings(current_lang()). Works with JS off via a GET<form action="/search">fallback (server renders results by calling the same logic). - Change routing: remove
@app.route("/")from thesite()catch-all (leave@app.route("/<path:path>")), add@app.route("/")→ renderSEARCH_HTML; add@app.route("/search")→ renderSEARCH_HTML(optionally server-render results ifqpresent, for no-JS). Add@app.route("/browse")→send_from_directory(SITE, "index.html")so the mkdocs manual index stays reachable, and link it from the search home (“Böngészés / Browse manuals”). - Add i18n keys for the new chrome (search_placeholder, results_count, no_results, browse_manuals, filter_vehicle) to
i18n.py(hu + en). - Update
test_app.py:/now returns the search UI (contains the search box marker) not the mkdocs index;/browseserves the mkdocs index; a manual page path still 200s via the catch-all. -
venv/bin/python -m unittest(full suite) → PASS.
Task 4: Live integration + verify + ship
Steps:
-
sudo systemctl restart knowledgebase; confirm it comes up (waitress serving; embedder warmed at startup — checkjournalctl -u knowledgebase -n 20for a clean start, note startup time). - Live queries:
curl -s "localhost:8092/api/search?q=fuel+cut+controller" | python3 -m json.tool | head→ relevant hits (right pages) ranked; try “why won’t the fuel pump prime”, “stop light switch”, afolder=suzuki-vitarafiltered query → restricted. Report top results +took_ms(< ~500 ms warm). - Wiring result deep-links to its
#kinyert-huzalozasi-adatok-aisection; article result points intoconsolidated/. - Home:
curl -s localhost:8092/ | grep -c "<search box marker>"= 1;/browseserves the mkdocs index (200); an existing manual page still 200;/semantics,/draftsstill 200 (no regression). - Graceful degradation:
docker stop kb-qdrant(or the Qdrant container) →/api/searchreturns 503,/still renders, mkdocs pages still serve; thendocker startit back. - Memory check:
ps -o rss= -p $(pgrep -f "python app.py")~1.3–1.6 GB resident (embedder) — sane vs 42 GB free. - Confirm non-destructive: Qdrant/docs/site/clusters.json untouched; backups present (
app.py.bak-search-fe).
Self-review
- Spec coverage: /api/search (T2), semantic backend reuse (T0/T2), search-first home + filters + deep links + badges (T3), keep mkdocs pages (T3 catch-all + /browse), i18n chrome (T3), graceful degradation (T4), no synthesis (retrieval only, T2). Covered.
- Ordering de-risks: T0 gates the in-process import (fallback to a service if deps won’t install) before any UI work.
- Type consistency:
searchui.dedupe_by_page/make_snippet/highlight/classify/vehicle_labeldefined T1, consumed T2;/api/searchJSON shape defined T2, consumed by T3 UI. - No placeholders; every code step shows real code or exact commands.