feat(rag): stream grounded RAG answers over SSE with source citations

Phase 03 (Story: Chat RAG Answer — happy path):
- app/rag/retriever.py: top-k cosine search + parent-doc selection with
  per-doc dedupe and BOR_MAX_CONTEXT_CHARS cap ([…truncated…] marker)
- app/rag/prompts.py: locked persona + HIGH/DEFLECT prompt builders
- app/rag/llm.py: LLMError + chat_stream (turbo, temp 0.4, max 700, stream)
- app/api/chat.py: POST /api/chat SSE — delta* then done{deflected,
  sources, suggestions}; query_log row + PLAN §9 per-turn log line;
  structured error event on mid-stream failure, JSON 503 when DB down
- frontend: SSE reader, live bubble streaming, source chips -> /sources.html,
  red role=alert banner, Send button state that always recovers
- fix(scaffold): [hidden] { display: none !important } — .kb-banner's
  display:flex was overriding the hidden attribute (banner always visible)
- tests: unit (retriever/prompts/sse/llm) + integration (real Postgres RAG
  turn, query_log, error + 503 paths, mid-turn failures) + Playwright story
  suite (grounded answer, log row, raw SSE shape); smoke placeholder test
  replaced with the real never-stale-button contract
This commit is contained in:
2026-08-21 17:17:02 -04:00
parent 99c48cbe06
commit 396e4d47fb
14 changed files with 1266 additions and 59 deletions
+97 -1
View File
@@ -10,12 +10,13 @@ from __future__ import annotations
import asyncio
import json
from types import SimpleNamespace
from typing import Any
import pytest
from app.config import Settings
from app.rag.llm import EmbeddingDimensionError, EmbeddingError, LLMClient
from app.rag.llm import EmbeddingDimensionError, EmbeddingError, LLMClient, LLMError
def _settings(**kwargs: Any) -> Settings:
@@ -230,3 +231,98 @@ def test_single_oversized_text_fails_actionably() -> None:
with pytest.raises(EmbeddingError, match="token cap"):
asyncio.run(llm.embed(["x" * 3000]))
assert llm.embed_batches == 0
# ---------- chat streaming (phase 03) ----------
def _chunk(content: str | None = "text", empty: bool = False):
"""One fake ChatCompletionChunk (``choices[].delta.content`` shape)."""
if empty:
return SimpleNamespace(choices=[])
return SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content=content))])
class _FakeChatStream:
def __init__(self, chunks: list) -> None:
self._chunks = list(chunks)
def __aiter__(self):
self._i = 0
return self
async def __anext__(self):
if self._i >= len(self._chunks):
raise StopAsyncIteration
chunk = self._chunks[self._i]
self._i += 1
return chunk
class _FakeCompletions:
def __init__(self, chunks: list | None = None, fail: Exception | None = None) -> None:
self.chunks = chunks or []
self.fail = fail
self.kwargs: dict | None = None
async def create(self, **kwargs) -> _FakeChatStream:
self.kwargs = kwargs
if self.fail is not None:
raise self.fail
return _FakeChatStream(self.chunks)
def _make_stream_client(
chunks: list | None = None, fail: Exception | None = None
) -> tuple[LLMClient, _FakeCompletions]:
completions = _FakeCompletions(chunks, fail)
fake_openai = SimpleNamespace(chat=SimpleNamespace(completions=completions))
llm = LLMClient(_settings())
llm._client = fake_openai # pyright: ignore[reportAttributeAccessIssue]
return llm, completions
async def _collect(llm: LLMClient, messages: list[dict[str, str]]) -> list[str]:
return [p async for p in llm.chat_stream(messages)]
def test_chat_stream_yields_deltas_in_order() -> None:
llm, completions = _make_stream_client(
[_chunk("Hey "), _chunk("you've "), _chunk("got this! 🧠")]
)
pieces = asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
assert pieces == ["Hey ", "you've ", "got this! 🧠"]
def test_chat_stream_uses_locked_generation_params() -> None:
llm, completions = _make_stream_client([_chunk("x")])
messages = [{"role": "system", "content": "s"}, {"role": "user", "content": "u"}]
asyncio.run(_collect(llm, messages))
assert completions.kwargs is not None
assert completions.kwargs["model"] == "turbo"
assert completions.kwargs["stream"] is True
assert completions.kwargs["temperature"] == 0.4
assert completions.kwargs["max_tokens"] == 700
assert completions.kwargs["messages"] == messages
def test_chat_stream_skips_empty_deltas_and_choiceless_chunks() -> None:
llm, _ = _make_stream_client([_chunk("a"), _chunk(empty=True), _chunk(None), _chunk("b")])
assert asyncio.run(_collect(llm, [{"role": "user", "content": "q"}])) == ["a", "b"]
def test_chat_stream_wraps_failures_as_llm_error() -> None:
llm, _ = _make_stream_client(fail=RuntimeError("connection reset by peer"))
async def drain() -> None:
async for _ in llm.chat_stream([{"role": "user", "content": "q"}]):
pass
with pytest.raises(LLMError, match="connection reset by peer"):
asyncio.run(drain())
def test_chat_stream_llm_error_passes_through_unwrapped() -> None:
llm, _ = _make_stream_client(fail=LLMError("already wrapped"))
with pytest.raises(LLMError, match="already wrapped"):
asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))