feat(chat): stream model thinking over SSE and show it in a collapsible block

This commit is contained in:
2026-08-24 09:52:27 -04:00
parent cbc263a4b2
commit b16deb2b1d
18 changed files with 1045 additions and 63 deletions
+80 -3
View File
@@ -28,7 +28,7 @@ from app.config import Settings, get_settings
from app.main import app as fastapi_app
from app.models import Chunk, QueryLog
from app.rag.importer import import_sources
from app.rag.llm import EmbeddingError, LLMError
from app.rag.llm import EmbeddingError, LLMError, StreamPiece
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "docs"
QUESTION = "How is my Kubernetes cluster set up?"
@@ -53,6 +53,7 @@ class FakeRagLLM:
def __init__(
self,
answer: str = "Hey — you've got this! Talos, Cilium, three nodes. 🧠",
thinking: str = "",
embed_error: Exception | None = None,
stream_error: Exception | None = None,
fail_mid_stream: bool = False,
@@ -60,6 +61,7 @@ class FakeRagLLM:
self.settings = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
self.embed_batches = 0
self.answer = answer
self.thinking = thinking
self.embed_error = embed_error
self.stream_error = stream_error
self.fail_mid_stream = fail_mid_stream
@@ -77,14 +79,20 @@ class FakeRagLLM:
return _token_vec(text)
async def chat_stream(self, messages: list[dict[str, str]]):
"""Typed stream (phase 17): ``thinking`` slices (same 12-char
cadence as content) **before** the content pieces. With the
default ``thinking=""`` this yields content-only pieces — today's
behavior, new yield type."""
self.seen_messages.append(messages)
if self.stream_error is not None:
raise self.stream_error
if self.fail_mid_stream:
yield "partial "
yield StreamPiece("content", "partial ")
raise LLMError("mid-stream dropout")
for i in range(0, len(self.thinking), 12):
yield StreamPiece("thinking", self.thinking[i : i + 12])
for i in range(0, len(self.answer), 12):
yield self.answer[i : i + 12]
yield StreamPiece("content", self.answer[i : i + 12])
@pytest.fixture()
@@ -150,6 +158,75 @@ def test_chat_streams_deltas_then_done_with_sources(client, db, seeded_kb: FakeR
assert "HONESTY GATE" in system["content"]
def test_chat_streams_thinking_before_deltas(client, db, seeded_kb: FakeRagLLM) -> None:
"""Phase 17: ``thinking`` frames precede every ``delta`` frame and
reassemble to the model's reasoning; the ``done`` contract is
unchanged."""
thinker = FakeRagLLM(
thinking=(
"Step 1: parse the question. Step 2: check the kubernetes doc. "
"Step 3: name Talos, Cilium, three nodes. Step 4: answer."
)
)
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: thinker
try:
_, _, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
thinking = [f for f in frames if f.get("type") == "thinking"]
deltas = [f for f in frames if f.get("type") == "delta"]
assert len(thinking) >= 1 # genuinely streamed
assert len(deltas) >= 2
# Every thinking frame precedes every delta frame.
ordered = [f["type"] for f in frames if f["type"] in ("thinking", "delta")]
assert ordered == ["thinking"] * len(thinking) + ["delta"] * len(deltas)
assert all(set(f.keys()) == {"type", "text"} for f in thinking)
assert "".join(f["text"] for f in thinking) == thinker.thinking
assert "".join(d["text"] for d in deltas) == thinker.answer
# Done still last; sources unchanged by the thinking extension.
done = frames[-1]
assert done["type"] == "done"
assert done["deflected"] is False
assert done["suggestions"] == []
assert done["sources"][0]["path"] == "homelab/kubernetes.md"
assert done["sources"][0]["source"] == "docs"
assert not any(f.get("type") == "error" for f in frames)
def test_chat_thinking_suppressed_when_disabled(
client, db, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 17 kill-switch: ``BOR_STREAM_THINKING=0`` drops every
``thinking`` frame; the delta stream is byte-identical to the
thinking-free case."""
thinker = FakeRagLLM(thinking="hidden reasoning that must never reach the wire")
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: thinker
# Same honesty gate the conftest/module already use (mock-calibrated
# 0.30 from the environment) — only the kill-switch changes.
live = get_settings()
monkeypatch.setattr(
chat_api,
"get_settings",
lambda: Settings(
_env_file=None, # pyright: ignore[reportCallIssue]
relevance_threshold=live.relevance_threshold,
stream_thinking=False,
),
)
try:
_, _, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
assert not any(f.get("type") == "thinking" for f in frames)
deltas = [f for f in frames if f.get("type") == "delta"]
assert "".join(d["text"] for d in deltas) == thinker.answer
assert frames[-1]["type"] == "done"
assert not any(f.get("type") == "error" for f in frames)
def test_chat_writes_query_log_row(client, db, seeded_kb: FakeRagLLM) -> None:
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try: