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:
@@ -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"}]))
|
||||
|
||||
Reference in New Issue
Block a user