feat(chat): stream model thinking over SSE and show it in a collapsible block
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user