Skip to content

Latest commit

 

History

History
275 lines (223 loc) · 15.5 KB

File metadata and controls

275 lines (223 loc) · 15.5 KB

mtchat — Roadmap

A local-first Q&A chatbot. The admin edits Q&A pairs in a MonkeyTab table; the chat answers user questions by matching against those pairs. Pure Deno + Hono + React, no external services required.

Why this exists

Most chat interfaces assume you have an LLM API key, a vector database, and a RAG pipeline. mtchat is the opposite: a small local-first app where you curate question/answer pairs in an editable table, and end-users ask questions in a chat that returns the closest match deterministically. No vendor key, no embedding API, no drift.

Good fit for:

  • Static FAQ chat for a product or landing page
  • Internal helpdesks where predictable answers matter
  • Support docs where auditability beats open-ended generation
  • Any context where you want to curate the answers yourself and know exactly what users will see

Stack

Layer Choice Why
Runtime Deno Ships with TypeScript, fmt, lint, test
Server Hono Tiny, fast, composable, JSX-aware
Server JSX hono/jsx with precompile Renders the page shell server-side
Browser UI React 19 Mounted client-side for /admin and /chat
Admin table @datasketch/monkeytab via npm: Drop-in editable React table — cell editing, sort/filter, virtualization out of the box
Chat UI @assistant-ui/react via npm: Headless React primitives, streaming-ready, MIT, the consensus answer for React chat in 2026
Bundler deno bundle (built-in, Deno ≥2.4) First-party, ~0 plumbing, bundles client/*.tsxstatic/*.js with code splitting and source maps
Store In-memory Map behind a QAStore interface Easy to swap for JSON file → SQLite later
Retrieval Token-overlap scoring with threshold Deterministic, testable, no embeddings API needed
Generation Stub in v1, slot-in interface for LLM later Useful as a static FAQ even with no LLM key

File layout

mtchat/
├── deno.json                  # imports + tasks (dev, build:client, start, test)
├── README.md
├── ROADMAP.md                 # this file
├── main.tsx                   # Hono entry — API routes + page shells + static
├── routes/
│   ├── api/
│   │   ├── qa.ts              # GET / POST / PATCH / DELETE /api/qa[/:id]
│   │   └── chat.ts            # POST /api/chat — { query, history } → { answer, ... }
│   ├── admin.tsx              # GET /admin — server-rendered shell
│   └── chat.tsx               # GET /chat — server-rendered shell, also /
├── lib/
│   ├── store.ts               # QAStore interface + InMemoryQAStore impl
│   ├── retrieval.ts           # match() — token-overlap scoring + threshold
│   ├── llm.ts                 # LlmProvider interface + StubProvider
│   └── seed.ts                # demo Q&A entries
├── views/                     # Hono JSX server shells (no client code)
│   ├── Layout.tsx             # <html><head><body> with nav + theme
│   ├── AdminShell.tsx         # mounts <div id="admin-root"> + script tag
│   └── ChatShell.tsx          # mounts <div id="chat-root"> + script tag
├── client/                    # React, bundled for browser by esbuild
│   ├── admin.tsx              # imports @datasketch/monkeytab, mounts MonkeyTable
│   └── chat.tsx               # imports @assistant-ui/react, mounts Thread
├── static/                    # build outputs (gitignored)
│   ├── admin.js               # built from client/admin.tsx
│   ├── chat.js                # built from client/chat.tsx
│   └── style.css              # shared theme
├── scripts/
│   └── build-client.ts        # esbuild build script
├── tests/
│   ├── retrieval.test.ts      # token scoring, threshold edge cases
│   └── store.test.ts          # CRUD invariants
└── data/                      # (created by Phase 4) JSON / SQLite persistence

Data shapes

interface QAEntry {
  id: string; // ulid
  question: string; // the canonical question
  answer: string; // markdown allowed
  tags?: string[]; // optional, for filtering / future routing
  createdAt: string; // ISO
  updatedAt: string; // ISO
}

interface ChatRequest {
  query: string;
  history?: { role: 'user' | 'assistant'; content: string }[];
}

interface ChatResponse {
  answer: string; // markdown
  confidence: number; // 0..1
  source: 'qa' | 'suggestions' | 'none' | 'llm';
  matched?: { question: string; score: number };
  suggestions?: { text: string }[]; // when below threshold
  _debug?: { duration_ms: number; candidates: number };
}

API surface

Method Path Purpose
GET / Redirects to /chat
GET /chat Server-rendered chat page shell
GET /admin Server-rendered admin page shell
GET /api/qa List all Q&A entries (consumed by MonkeyTable)
POST /api/qa Create one
PATCH /api/qa/:id Update one (called from MonkeyTable onChange)
DELETE /api/qa/:id Delete one
POST /api/chat Query the Q&A system, returns ChatResponse
GET /static/* Built JS bundles + CSS

Phased plan

Phase 0 — Bootstrap

Goal: server boots, serves placeholder pages, in-memory store works.

  • deno.json with imports for hono, @hono/hono/jsx, react, react-dom, @datasketch/monkeytab, @assistant-ui/react, esbuild
  • main.tsx with Hono app: /, /admin, /chat, /static/*, /api/qa*, /api/chat
  • lib/store.ts: QAStore interface (list, get, create, update, delete) + InMemoryQAStore implementation
  • lib/seed.ts: ~10 demo Q&A entries covering mtchat's own features (what is it, how to edit, where data is stored, how retrieval works)
  • routes/api/qa.ts: thin wrappers around the store
  • Page shells render plain HTML for now — no React mount yet
  • Smoke test: curl localhost:8000/api/qa returns the seed

Done when: all routes respond, seed entries are CRUD-able via curl, no UI yet.

Phase 1 — Admin page with MonkeyTab

Goal: real editable Q&A table in the browser, persisted in server memory.

  • client/admin.tsx: fetches /api/qa, renders <MonkeyTable> with columns:
    • question — Text, multiline, editable
    • answer — Text, multiline, editable
    • tags — MultiSelect, options derived from existing tags
    • updatedAt — Date, read-only
  • onChange POSTs the diff back to /api/qa/:id (uses onCellChange for granular updates)
  • scripts/build-client.ts: esbuild bundles client/admin.tsxstatic/admin.js, externalizing react/react-dom (loaded via CDN in the shell or bundled — TBD based on size)
  • views/AdminShell.tsx: minimal HTML with <div id="admin-root"> and <script type="module" src="/static/admin.js">
  • Hot reload: deno task dev watches client/ and rebuilds on save

Done when: opening /admin shows an editable table; edits round-trip; reloading the page shows the edits (in-memory only — Phase 4 adds persistence).

Phase 2 — Chat page (deterministic, no LLM)

Goal: a real chat UI that answers questions from the Q&A table.

  • lib/retrieval.ts: match(query, entries) → { matched, candidates, score }
    • Tokenize query and questions (lowercase, strip punctuation, split on whitespace)
    • Score by token-overlap ratio (intersection / union of token sets) — Jaccard for v1
    • Threshold default 0.3, configurable via env var
    • Returns top match if above threshold, otherwise top-3 candidates as "did you mean"
  • routes/api/chat.ts: POST /api/chat → calls retrieval.match, returns ChatResponse
  • client/chat.tsx: mounts @assistant-ui/react <Thread> with a custom runtime adapter that calls /api/chat
    • Map ChatResponse.answer → assistant message
    • When source === 'suggestions', render the suggestion chips below the message
    • When source === 'qa', show a small matched: "..." footer as a citation
  • views/ChatShell.tsx: HTML shell with <div id="chat-root"> + /static/chat.js
  • Greeting message ("Hi! Ask me anything from the knowledge base.") seeded by the runtime
  • tests/retrieval.test.ts: scoring edge cases (empty query, no matches, exact match, partial match, all stop words)

Done when: typing a question in /chat returns the matching answer; below-threshold queries show suggestion chips; tests pass.

Phase 3 — Slot-in LLM rephrasing layer

Goal: optionally let an LLM smooth the deterministic answer into natural prose, without making it required.

  • lib/llm.ts: LlmProvider interface with one method:
    generate(input: { query, matched, history, systemPrompt }): Promise<string>
  • Implementations:
    • StubProvider — returns the matched answer verbatim. Default.
    • AnthropicProvider — calls Claude via npm:@anthropic-ai/sdk, off by default
  • Toggle via MTCHAT_LLM=stub|anthropic env var; key from ANTHROPIC_API_KEY
  • The chat route always asks the provider to format — if it's the stub, you get deterministic output; if it's Anthropic, you get a natural rewording grounded in the matched Q&A
  • Respect history (last N turns) for multi-turn context
  • Streaming: assistant-ui supports streaming responses via async iterables; the Anthropic provider implements that, the stub returns instant

Done when: flipping the env var swaps between deterministic and LLM output without code changes; the chat still works with no API key set.

Phase 4 — Persistence

Goal: survive restarts.

  • Add JsonFileQAStore implements QAStore — same interface, atomic writes to data/qa.json
  • Then SqliteQAStore via jsr:@db/sqlite — same interface, schema migration handled in constructor
  • Store selection via MTCHAT_STORE=memory|json|sqlite env var, default memory
  • The QA shape doesn't change — only the storage layer
  • Migration utility: deno task export and deno task import for moving between stores

Done when: flipping MTCHAT_STORE swaps the implementation without touching API or UI code; tests run against all three stores.

Phase 5 — Quality & polish

Goal: make it pleasant.

  • Tags filter in admin (MonkeyTable already has filter UI — wire it up)
  • Multi-conversation history sidebar in chat (named threads, deletable)
  • Theme: light/dark, configurable via ?theme= URL param
  • Keyboard shortcut: / focuses chat input from anywhere
  • Export the current Q&A set as Markdown (so you can publish it as a static FAQ alongside the chat)
  • Tests: end-to-end Playwright test for both pages
  • Accessibility audit (assistant-ui handles most of it; verify the admin page too)

Phase 6 — Better retrieval (when token-overlap stops being enough)

Trigger: when the Q&A corpus passes ~50 entries and false negatives become noticeable.

  • Option A: SQLite FTS5 — built-in to jsr:@db/sqlite, BM25 scoring, multi-word, no external deps. First choice.
  • Option B: Embeddings — local model via jsr:@huggingface/transformers or remote via Anthropic/OpenAI. More accurate, more dependencies, more latency.
  • The retrieval.ts interface stays — only the implementation changes.

Architectural decisions worth defending

  1. Single Hono server — API + page shells + static bundles all from main.tsx. No separate API service, no client/server framework split.

  2. Server-rendered HTML shell + client-mounted React per page, not full SPA. Each page (/admin, /chat) bundles its own client entry point. Smaller bundles, no router on the client, no hydration mismatch. The Hono JSX shell can stay simple because it doesn't need to render the interactive parts.

  3. QAStore interface from day one — even though Phase 0 is in-memory only. The interface is the contract; storage is an implementation detail. This is the only way to swap to SQLite later without rewriting the API.

  4. Deterministic Q&A first, LLM optional — mtchat is genuinely useful as a static FAQ chat with no LLM key. The LLM is a layer, not the core. This keeps the project local-first in spirit, makes it testable without API keys, and means the bus factor for keeping the demo working is exactly zero.

  5. Headless chat UI library (assistant-ui), not a styled widget. We control the look so mtchat doesn't feel like "another generic chat clone." The library handles auto-scroll, accessibility, markdown, streaming, and the tedious parts. We handle the brand, the layout, and the Q&A integration.

What mtchat explicitly does NOT do (in v1)

  • No vector embeddings, no RAG pipeline (Phase 6 if needed)
  • No authentication / multi-user (single-user local app)
  • No persistence by default (Phase 4 adds it)
  • No streaming markdown rendering tricks beyond what assistant-ui ships
  • No hosted deployment (Deno Deploy is fine, but the design is local-first first)
  • No agent / tool calling (Phase 6+ if it makes sense)
  • No file uploads / attachments
  • No conversation memory beyond the current thread (Phase 5 adds a sidebar)

Open questions to revisit at each phase boundary

  • Should the admin and chat pages share a layout? Probably yes after Phase 2. A simple top nav with chat | admin | github.
  • Where does the Q&A schema get its id? ulid, uuid, or human-readable slug? Lean ulid for now.
  • Do we expose the API publicly or keep it localhost-only by default? Localhost-only by default. Add a MTCHAT_BIND env var for Phase 4.