feat(rag): local LLM RAG assistant for the chat widget (phase 1) - #297
Draft
italovalcy wants to merge 5 commits into
Draft
feat(rag): local LLM RAG assistant for the chat widget (phase 1)#297italovalcy wants to merge 5 commits into
italovalcy wants to merge 5 commits into
Conversation
Extends the support chat with a second answering path: a local RAG assistant grounded on the deployment's own documentation, chosen by an explicit "support case vs. question" step at the start of a conversation. The model and retrieval stack live in a separate container so they can be scaled, relocated, or repointed at a GPU host without touching the dashboard image. The primary target is a CPU-only server, which shapes the design: a small quantized model, one generation slot behind a bounded queue, answer caching, short contexts, and a fallback that is never worse than today's behaviour (a human support case). Also covers refusal as a first-class outcome, source citations, answer feedback from day one, escalation with its own telemetry snapshot, local document ingestion, and local-only privacy/logging controls. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Off by default (RAG_ENABLED=False): with the flag unset the chat widget
behaves exactly as it did before, human support only and no mode chooser.
The service (rag/) is its own container speaking a small HTTP contract:
/v1/answer, /v1/ingest, /healthz, /v1/stats. Its retrieval and grounding
logic has no hard third-party dependencies -- only the HTTP layer needs
FastAPI, and the model libraries are imported lazily by the backend that
uses them -- so it is unit-testable with no weights at all. Defaults are
the CPU profile: one generation slot behind a bounded queue that rejects
overflow rather than making users wait, answer and embedding caches keyed
on the index version, ~1600-token prompts, and a streamed generation with
a real wall-clock deadline. Switching to a GPU host (DGX Spark or
similar) is RAG_LLM_BACKEND=openai_compat plus a base URL: same contract,
no dashboard code change.
Refusal is a first-class outcome, gated twice: retrieval below
RAG_MIN_SCORE refuses without spending a generation slot, and an answer
that cites nothing is refused after the fact. Only cited blocks come back
as sources. The user-facing refusal and fallback texts are rendered by
the dashboard through Flask-Babel, so the model is never trusted to
produce a correct pt-BR error message.
Dashboard side:
- SupportThreads.mode ("support" | "assistant") and .locale;
SupportMessages.meta (citations, diagnostics, escalation snapshot) plus
dedicated feedback columns -- feedback is the one thing aggregated in
SQL, so it does not live in JSON. Migration 2.0.14 -> 2.0.15.
- rag_client.py: timeout, bearer auth and a circuit breaker, with a hard
contract that it never raises into the request path. A 503 (saturated)
does not count toward opening the breaker; a broken service does.
- The answer is fetched by a *second* request, not inline in the message
POST: generation takes seconds, and this way the user's message is
already stored and can be escalated without retyping if it fails.
- An assistant thread is not staff work until escalated -- excluded from
the sidebar badge and from the batched support e-mail.
- Escalation snapshots telemetry at the hand-over (the page the user gave
up on, which may be far from where the conversation started), without
overwriting the thread's own origin_page.
- Widget: mode chooser, typing indicator, citations, thumbs up/down with
reasons, and "Talk to a human" emphasized after a refusal or a
down-vote. Admin panel reports refusal rate next to thumbs-down rate.
- rag-ingest / rag-health CLI, a curated bilingual FAQ under doc/faq/,
and pt_BR translations for every new string.
Tests: tests/test_rag_assistant.py (43) and rag/tests (29), all running
without the service and without model weights.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A hybrid reasoning model (Qwen3, DeepSeek-R1, ...) emits a <think>...</think> scratchpad before its answer. Left in, it would land in the chat bubble and derail the grounding post-check (the scratchpad rarely carries the [1] citations the real answer does, so a valid answer would look "not grounded" and be refused). strip_reasoning() removes well-formed blocks and any dangling unclosed one (a generation cut short by the wall-clock deadline), and is applied in both the llamacpp and openai_compat backends -- a reasoning model can be served either way. It is a no-op for the non-reasoning 3B-class default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The gate waited for a slot with an unbounded timeout (threading.Semaphore.acquire(timeout=None)). On a slow host a generation can outlast the dashboard's client timeout, so a user who retries after the fallback leaves a request parked in the queue forever -- the client is gone, but the server thread keeps its inflight slot. After a few retries the queue is permanently full and every later request, even from a lone user hours later, is rejected as "answering someone else" until the process restarts. Bound the wait: RAG_QUEUE_WAIT_S (default 30s, wired through the pipeline), and coerce a None wait to a finite bound in the gate itself so it can never be reintroduced. A queued request that cannot be served in time now evicts itself and frees its slot, and the queue self-heals. Also document RAG_QUEUE_MAX=0 as the honest setting for a CPU box where a generation already approaches the client timeout (a queued request would only be served after the client gave up), and fix a flaky conversation- hash test that asserted a substring absence in a random hex digest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The widget reveals the reason chips only after the thumb is pressed, so a
down-vote with a reason arrives as two calls: {vote: down}, then
{vote: down, reason: ...}. record_feedback treated the second call as a
repeat of the same vote and toggled it off, silently erasing the very
down-vote the reason was meant to explain -- so anyone who picked a reason
left no feedback, and the admin panel's "Recent negative feedback" stayed
empty.
Toggle off only on a bare re-click of the same thumb (no reason attached);
a repeat vote that carries a reason now annotates the existing vote. The
existing tests passed because they sent vote+reason in a single call,
which the widget cannot do; add a test for the real two-call sequence.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Extends the support chat with a second answering path: a local LLM RAG assistant
grounded on the deployment's own documentation, chosen by an explicit
"support case vs. question" step at the start of every new conversation.
Design and rationale:
doc/rag-assistant-design.md.Service docs:
rag/README.md.Off by default. With
RAG_ENABLEDunset the widget behaves exactly as it doestoday — human support only, no mode chooser, no new calls.
What it does
en/pt_BR, following the existing localechain) even when the documentation it cites is in the other one.
telemetry captured at the hand-over.
Architecture
The model and retrieval stack run in a separate container (
rag/) behind a smallHTTP contract (
/v1/answer,/v1/ingest,/healthz,/v1/stats). The dashboardnever imports a model library, never holds an index, and never blocks on one: it is a
thin client with a timeout, a circuit breaker and a fallback to the human path.
Its retrieval and grounding logic has no hard third-party dependencies — only the
HTTP layer needs FastAPI, and the model libraries are imported lazily by the backend
that uses them — so the whole pipeline is unit-testable with no weights at all.
CPU-constrained by default
Defaults target a CPU-only host: a 3B-class quantized model, one generation slot
behind a bounded queue that rejects overflow (
503) rather than making users waitbehind three other people, answer + embedding caches keyed on the index version,
~1600-token prompts, and streamed generation with a real wall-clock deadline.
Moving to a GPU host (DGX Spark or similar) is
RAG_LLM_BACKEND=openai_compatplus abase URL, then relaxing the limits that only existed because of the CPU — same
contract, no dashboard code change.
Grounding
Refusal is a first-class outcome, gated twice:
RAG_MIN_SCORErefuses without spending ageneration slot (also the cheapest possible answer for off-topic questions);
NO_ANSWERsentinel is not trusted alone; ananswer that cites nothing is refused too.
The user-facing refusal and fallback texts are rendered by the dashboard through
Flask-Babel, so the model is never trusted to produce a correct pt-BR error message,
and adding a locale does not mean redeploying the model container.
Decisions worth reviewing
seconds on CPU; this way the user's message is already persisted and can be escalated
without retyping if generation fails. gunicorn's gevent worker makes holding the
request cheap.
modeis a thread column, not a new table — the admin pages, user pages ande-mail batching keep working, they just filter on
mode == "support". An assistantthread is not staff work until escalated: excluded from the sidebar badge and
from the batched support e-mail.
metaJSON — it is the one partthat gets aggregated in SQL, and portable JSON querying across
SQLite/MySQL/PostgreSQL is not worth it for three scalars.
record_telemetryfires only at thread creation, which is the wrong moment here: the conversation may
have started on the home page six questions earlier, while the page staff needs is
wherever the user pressed "Talk to a human". It is stored on the escalation
systemmessage'smeta(no extra schema) and does not overwrite the thread'sown
origin_page— there is a test for exactly that regression.RAG_STORE_TRANSCRIPTS=Falsealso disables feedback, end to end: with no storedmessage there is nothing to attach a vote to. Documented as a real cost of that
privacy knob rather than left implicit.
Privacy
The service needs no egress (the Dockerfile sets the offline env vars; a
NetworkPolicyis the actual guarantee). Weights are baked in or mounted, neverfetched at runtime. The dashboard sends the question, the locale and a pseudonymous
conversation hash — no user id, name, e-mail or IP. Feedback and escalation telemetry
never leave the dashboard.
RAG_LOG_QUERIESselectsnone(default) /hashed/full.Testing
689 passed, 3 skipped— the skips are the FastAPI HTTP-layer tests, which skipthemselves when FastAPI is not installed. djlint clean, coverage 88% (floor 85%).
tests/test_rag_assistant.py(43) — mode routing, the three answer outcomes and thefact that the user's message survives all three, localization, escalation telemetry,
feedback, the circuit breaker, the rate limit, transcripts-off, ingestion.
rag/tests/(29) — chunking, the store, cache invalidation on re-ingest, queueoverflow, the retrieval gate, refusal paths, citation extraction.
rag/testsas its own step.The migration (
2.0.14 → 2.0.15) was applied, rolled back and re-applied against areal SQLite database — the test suite uses
create_all()and would not have caught abad migration.
Not in this PR
Phases 2–5 from the design: lab-guide/lab-description ingestion beyond the collectors
(they exist and are opt-in via
RAG_INGEST_SOURCES), token streaming, reranking, andthe accelerated backend rollout.
Two things that could not be verified locally:
rag/requirements.txthas never beenresolved on a build host, so it uses version ranges with a note to pin from
pip freezeafter the first successful image build; and the Dockerfile's model-weightCOPYlines are commented out, since the weights are deployment-supplied.Try it
Prints exactly what the assistant would know, without contacting the service.