Plan — Context Cards (#106)¶
A lightweight, deterministic glossary path that lives alongside
kb_search(NOT a replacement).kb_searchis slow on prod because reasoning can't be disabled across GLM/Qwen/Gemma on vLLM (every expand/rerank/agent turn<think>s) and the agent loops 3–5 searches. We do not fix that here — kb_search perf + per-model/vLLM reasoning-off is a separate later issue. This issue adds context cards: a term → explanation lookup answered by an exact, no-LLM, no-embedding DB query. Follow/tddper phase (FE too, vitest). Gate at the end: full suite + 100% coverage (no pipe-mask),ruff,ty, FE typecheck + build.
Scope¶
In: ContextCard resource on a Collection; deterministic exact-key lookup
(get, batch, exposed) + free-text scan (match, internal); CRUD + a FE tab; inject
matched cards into the KB chat agent.
Out (deferred): kb_search latency / reasoning-off on vLLM; RCA-agent injection; fuzzy/semantic matching; third-party API-key auth (reuse existing app auth).
Design (locked in grill)¶
ContextCard (specstar Struct, → resource "context-card", attached to a Collection)
collection_id: Ref("collection", cascade) # indexed — list/scope/match-load
keys: list[str] # author surface forms: ["M4","Metal 4","capping"]
norm_keys: list[str] # DERIVED + indexed; server-owned, never hand-edited
title: str = "" # display; "" → keys[0]
body: str = "" # markdown explanation (plain str, not Binary)
norm(s) = " ".join(unicodedata.normalize("NFKC", s).casefold().split())— deliberately simple so external callers replicate it. No prefix/fuzzy.- key ↔ card is many-to-many → every lookup returns
list[ContextCard]. No key-uniqueness constraint. - Core primitive (both consumers):
QB["norm_keys"].contains(norm(q))—.containson alist[str]is exact element membership (so"M4"≠"M40"), the same index path already used byKbChat.shared_with. Single resource, no join table.
norm_keys derivation = specstar custom actions (@spec.create_action /
@spec.update_action, specstar howto/routes).
The handler receives the author input (create) or the injected existing + patch
(update), returns a ContextCard with norm_keys=derive_norm_keys(keys), and
specstar persists it via rm.create() / rm.update() in the same write — no
event handler, no write-back loop, derivation can't be bypassed by the FE because the
FE authors through these actions. (These are CRUD-layer routes → P2.)
Open decision — resolved as default (cheap to flip):
- title kept (friendly display name distinct from a key; falls back to keys[0]).
Key existing seams to hook (don't rebuild)¶
KbChat.shared_with— precedent for an indexedlist[str]queried withQB[...].contains(x)(resources/__init__.pyadd_model(KbChat, indexed_fields=["shared_with"]),api/kb_chat_routes.py:295).norm_keyscopies it verbatim.- specstar auto-CRUD —
add_modelemits CRUD routes; don't wrap to hide them ([[feedback_specstar_routes_fine]]). FE list/create/update/delete ride these. event_handlers.extend(...)post-add_model—kb/index_coordinator.pyis the pattern; scope toon_success(patch), swallow exceptions ([[reference_specstar_event_handlers]]).- #87 monaco editor (
feat/issue-87-kb-doc-ide) — reuse the FE editor component forbody; backend stays a plainstr(no FileStore plumbing). - KB chat turn build (
api/kb_chat_routes.py+api/turns.py ChatTurnEngine, KBAgentToolContext) — the pre-pass injection point formatch(text). Ref(..., on_delete=cascade)— cards die with their Collection (like SourceDoc).- pydantic response models for the lookup endpoint ([[feedback_pydantic_response_models]]).
Phases (flat; FE/CRUD pulled early so cards can be hand-authored to test)¶
P1 — ContextCard resource + norm() + derive_norm_keys() (pure core)¶
ContextCardStruct inresources/kb.py; registeradd_model(ContextCard, indexed_fields=["collection_id", "norm_keys"])inmake_spec.kb/context_cards.py:norm(s)andderive_norm_keys(keys) -> sorted({norm(k)…}).- TDD:
norm()cases (casefold / NFKC full-width / whitespace collapse);derive_norm_keyssorted + unique + drops blanks; resource registered + create/get roundtrip; cascade-delete with Collection.
P2 — CRUD via custom actions + FE "Context Cards" tab¶
- Backend:
@spec.create_action("context-card", path="author")+@spec.update_action("context-card", path="edit", mode="update")whose handlers setnorm_keys=derive_norm_keys(keys)(the only write path the FE uses → derivation can't be bypassed). No hand-rolled read route — list/get/delete ride specstar's auto CRUD; the FE lists a collection's cards viaGET /context-card?qb=QB['collection_id'] == '<cid>'(collection_id indexed → a query, not a scan) and reads the specstar envelope (item.data,item.revision_info.resource_id). The create-action module must NOT usefrom __future__ import annotations(specstar resolves the action body type at apply()-time and can't follow stringised ForwardRefs). - FE: a "Context Cards" tab on the collection page — left list (
useQuery, key inqueryKeys.ts), center monaco (reuse #87) forbody+ a keys tag editor + New; save =useMutation(POST the create/update action) +invalidateQueries.useCurrentUsernot hardcoded. - TDD (backend + vitest): create action →
norm_keysderived & sorted-unique; update action → recomputed from newkeys; FE never sendsnorm_keys; list returns only the collection's cards; delete roundtrip; empty-state New.
P3 — get(term) deterministic query core¶
kb/context_cards.py:lookup(spec, collection_id, terms) -> dict[str, list[ContextCard]]— per term(QB["collection_id"] == cid) & QB["norm_keys"].contains(norm(t)); key the result by the original input term. (Iftermsis large, OR-combine thecontainsinto one query + group in Python —&/|both supported.)- TDD: exact hit; one key → multiple cards; one card → multiple keys; normalization
(case / full-width / whitespace); miss →
[];"M4"does NOT return the"M40"card (membership, not substring); other collection's card excluded.
P4 — external lookup API¶
POST /collections/{cid}/context-cards/lookupbody{terms: [...]}→ pydantic{results: {term: [Card...]}}; reuse existing app auth (internal authed users/services). Card response model =id, title, keys, body.- TDD: batch; unknown collection → 404; empty
terms→ empty; auth required; response matches the pydantic schema.
P5 — match(text) single-pass primitive¶
kb/context_cards.py:build_vocab(cards) -> {norm_key: [Card]};_word_ascii;_hits(nt, key)(locate viastr.find, accept an occurrence whose edges aren't glued to an ASCII word-char[A-Za-z0-9_]);match(text, vocab, cap=10)— one pass over vocab, dedupe cards, stable sort, cap. (Aho-Corasick is a later data-structure swap if vocab explodes — still one pass; not a second pass.)- TDD (boundary table):
m4rejectsm40;etchrejectsfoobar_etch; CJK key matches embedded (封蓋製程in這個封蓋製程的問題); multi-wordmetal 4; standalonem4; dedupe (card hit by 2 keys once); cap + deterministic truncation order; empty.
P6 — inject matched cards into the KB chat agent¶
- In the KB chat turn setup: pre-pass
match(user_message, vocab_of(collection_ids)), inject hit cards as a labelled context block into the agent context (hit → answered from the card; miss → normalkb_search, unchanged). Respect the cap;logdrops. - TDD (
ScriptedAgentRunner): a term with a card → its body present in the agent context, nokb_searchneeded; no hit → no injection,kb_searchpath intact; cap.
Gate (end)¶
Full suite + 100% coverage (no pipe-mask, per [[feedback_gate_no_pipe_mask]]), ruff
check + ruff format --check, ty check, FE typecheck + build. Live canned check:
author a card, get it via the API, and confirm a KB-chat term question answers from
the injected card without a kb_search round-trip.