Skip to content

feat(rag): local LLM RAG assistant for the chat widget (phase 1) - #297

Draft
italovalcy wants to merge 5 commits into
mainfrom
feat/rag
Draft

feat(rag): local LLM RAG assistant for the chat widget (phase 1)#297
italovalcy wants to merge 5 commits into
mainfrom
feat/rag

Conversation

@italovalcy

@italovalcy italovalcy commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Base note: this branch was cut from fix/group-expiration-date, so the PR is
based on that branch (#296) to keep the diff to just this feature. GitHub will
retarget it to main automatically once #296 merges.

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_ENABLED unset the widget behaves exactly as it does
today — human support only, no mode chooser, no new calls.

What it does

  • Answers in the user's language (en / pt_BR, following the existing locale
    chain) even when the documentation it cites is in the other one.
  • Refuses when the answer is not in the corpus, and offers a human instead.
  • Cites its sources — only the blocks the answer actually cited.
  • Collects 👍/👎 on every answer from the first release, with reasons.
  • Escalates to human support at any point, carrying the whole transcript plus
    telemetry captured at the hand-over.

Architecture

The model and retrieval stack run in a separate container (rag/) behind a small
HTTP contract (/v1/answer, /v1/ingest, /healthz, /v1/stats). The dashboard
never 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 wait
behind 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_compat plus a
base 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:

  1. before generation — retrieval below RAG_MIN_SCORE refuses without spending a
    generation slot (also the cheapest possible answer for off-topic questions);
  2. after generation — the model's NO_ANSWER sentinel is not trusted alone; an
    answer 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

  • The answer is a second request, not part of the message POST. Generation takes
    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.
  • mode is a thread column, not a new table — the admin pages, user pages and
    e-mail batching keep working, they just filter on mode == "support". An assistant
    thread is not staff work until escalated: excluded from the sidebar badge and
    from the batched support e-mail.
  • Feedback lives in dedicated columns, not in the meta JSON — it is the one part
    that gets aggregated in SQL, and portable JSON querying across
    SQLite/MySQL/PostgreSQL is not worth it for three scalars.
  • Escalation takes its own telemetry snapshot. The existing record_telemetry
    fires 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
    system message's meta (no extra schema) and does not overwrite the thread's
    own origin_page — there is a test for exactly that regression.
  • RAG_STORE_TRANSCRIPTS=False also disables feedback, end to end: with no stored
    message 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
NetworkPolicy is the actual guarantee). Weights are baked in or mounted, never
fetched 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_QUERIES selects none (default) / hashed /
full.

Testing

689 passed, 3 skipped — the skips are the FastAPI HTTP-layer tests, which skip
themselves when FastAPI is not installed. djlint clean, coverage 88% (floor 85%).

  • tests/test_rag_assistant.py (43) — mode routing, the three answer outcomes and the
    fact 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, queue
    overflow, the retrieval gate, refusal paths, citation extraction.
  • CI runs rag/tests as its own step.

The migration (2.0.14 → 2.0.15) was applied, rolled back and re-applied against a
real SQLite database — the test suite uses create_all() and would not have caught a
bad 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, and
the accelerated backend rollout.

Two things that could not be verified locally: rag/requirements.txt has never been
resolved on a build host, so it uses version ranges with a note to pin from
pip freeze after the first successful image build; and the Dockerfile's model-weight
COPY lines are commented out, since the weights are deployment-supplied.

Try it

flask --app run.py cli rag-ingest --dry-run

Prints exactly what the assistant would know, without contacting the service.

italovalcy and others added 5 commits July 24, 2026 06:25
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>
Base automatically changed from fix/group-expiration-date to main July 31, 2026 20:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant