feat(chat): stream model thinking over SSE and show it in a collapsible block
This commit is contained in:
+34
-5
@@ -8,6 +8,13 @@ persona prompt (PLAN §6) → ``turbo`` streamed as ``delta`` events → final
|
||||
Mid-stream failures become a structured ``error`` event; a pre-stream DB
|
||||
outage is a plain 503 JSON.
|
||||
|
||||
Thinking (phase 17, PLAN §4 extension, owner permission 2026-08-23): the
|
||||
model's reasoning arrives ahead of the answer and is streamed as
|
||||
``thinking`` events before the ``delta`` events of the same turn. Each
|
||||
turn's thinking is counted in the per-turn log line
|
||||
(``thinking_chars=N``); ``BOR_STREAM_THINKING=0`` suppresses the
|
||||
``thinking`` frames (the pieces are still counted).
|
||||
|
||||
Honesty gate (A8, revised 2026-08-21): LOW — deflection — only when the
|
||||
best cosine is strictly below ``BOR_RELEVANCE_THRESHOLD`` **and** no
|
||||
candidate chunk FTS-matches the question (``fts_hits == 0``). A
|
||||
@@ -39,11 +46,22 @@ from app.api.steering import load_steering_notes
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import db_available, get_db
|
||||
from app.models import Document, QueryLog
|
||||
from app.rag.llm import EmbeddingError, LLMClient, LLMError
|
||||
from app.rag.llm import (
|
||||
EmbeddingError,
|
||||
LLMClient,
|
||||
LLMError,
|
||||
StreamPiece, # noqa: F401 (phase 17 typing: chat_stream yields StreamPiece)
|
||||
)
|
||||
from app.rag.prompts import build_deflect_prompt, build_high_prompt
|
||||
from app.rag.retriever import RetrievedChunk, retrieve, select_documents, weak_hit_titles
|
||||
from app.rag.suggestions import derive_suggestions
|
||||
from app.schemas import ChatDoneEvent, ChatErrorEvent, ChatRequest, SourceRef
|
||||
from app.schemas import (
|
||||
ChatDoneEvent,
|
||||
ChatErrorEvent,
|
||||
ChatRequest,
|
||||
ChatThinkingEvent,
|
||||
SourceRef,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("app.chat")
|
||||
router = APIRouter(tags=["chat"])
|
||||
@@ -202,9 +220,19 @@ async def chat(
|
||||
]
|
||||
|
||||
# 3. Stream the answer (grounded, or an honest deflection).
|
||||
# Phase 17: thinking pieces stream as ``thinking`` events
|
||||
# ahead of the ``delta`` events (PLAN §4 extension); the
|
||||
# kill-switch (``BOR_STREAM_THINKING=0``) suppresses the
|
||||
# frames, not the counting.
|
||||
thinking_chars = 0
|
||||
try:
|
||||
async for piece in llm.chat_stream(messages):
|
||||
yield sse_event({"type": "delta", "text": piece})
|
||||
async for piece in llm.chat_stream(messages): # StreamPiece (phase 17)
|
||||
if piece.kind == "thinking":
|
||||
thinking_chars += len(piece.text)
|
||||
if settings.stream_thinking:
|
||||
yield sse_event(ChatThinkingEvent(text=piece.text).model_dump())
|
||||
else:
|
||||
yield sse_event({"type": "delta", "text": piece.text})
|
||||
except LLMError as e:
|
||||
logger.error(
|
||||
"chat: LLM stream failed question=%r total_ms=%d — %s",
|
||||
@@ -239,7 +267,7 @@ async def chat(
|
||||
|
||||
logger.info(
|
||||
"question=%r embed_ms=%d top_score=%.3f fts_hits=%d tuning=%d threshold=%.2f "
|
||||
"deflected=%s sources=%r total_ms=%d",
|
||||
"deflected=%s sources=%r thinking_chars=%d total_ms=%d",
|
||||
request.message,
|
||||
embed_ms,
|
||||
plan.top_score,
|
||||
@@ -248,6 +276,7 @@ async def chat(
|
||||
settings.relevance_threshold,
|
||||
plan.deflected,
|
||||
source_paths,
|
||||
thinking_chars,
|
||||
total_ms,
|
||||
)
|
||||
yield sse_event(
|
||||
|
||||
@@ -41,6 +41,11 @@ class Settings(BaseSettings):
|
||||
llm_api_key: str = ""
|
||||
llm_chat_model: str = "turbo"
|
||||
llm_embed_model: str = "embed"
|
||||
#: Operator kill-switch for the ``thinking`` SSE events (phase 17,
|
||||
#: ``BOR_STREAM_THINKING``; ``0``/``false`` → off). When off, thinking
|
||||
#: pieces are still counted for the per-turn log line but never
|
||||
#: emitted — the answer stream itself is unchanged.
|
||||
stream_thinking: bool = True
|
||||
|
||||
# --- RAG tuning ---
|
||||
embedding_dim: int = 768 # verified against aipi /v1 (embed model)
|
||||
|
||||
+54
-14
@@ -1,7 +1,12 @@
|
||||
"""Async OpenAI-compatible client for the self-hosted aipi endpoint (PLAN A5).
|
||||
|
||||
Provides the embeddings surface (importer, retrieval) and chat streaming
|
||||
(PLAN A15) for the RAG pipeline.
|
||||
(PLAN A15) for the RAG pipeline. Chat streaming yields typed
|
||||
:class:`StreamPiece` values (phase 17): aipi's ``turbo`` model streams
|
||||
its reasoning as ``delta.reasoning_content`` chunks (deepseek/litellm
|
||||
wire convention, verified live 2026-08-23) **before** the answer's
|
||||
``delta.content`` chunks, and reasoning counts against ``max_tokens``
|
||||
(an answer can in principle be empty).
|
||||
|
||||
Fail-loud rule (PLAN A6): the ``chunks.embedding`` column is fixed at 768
|
||||
dimensions when the table is created, so a model that returns a different
|
||||
@@ -12,7 +17,8 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import cast
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, cast
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
from openai.types.chat import ChatCompletionMessageParam
|
||||
@@ -34,6 +40,19 @@ class LLMError(RuntimeError):
|
||||
"""The chat-completions endpoint failed (network, HTTP, or mid-stream)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StreamPiece:
|
||||
"""One piece of a streamed chat turn (phase 17, PLAN §4 extension).
|
||||
|
||||
``kind`` is ``"content"`` for answer text (an SSE ``delta`` frame)
|
||||
or ``"thinking"`` for the model's reasoning (an SSE ``thinking``
|
||||
frame). Frozen: pieces are immutable wire values, not accumulators.
|
||||
"""
|
||||
|
||||
kind: Literal["content", "thinking"]
|
||||
text: str
|
||||
|
||||
|
||||
# aipi's local embedding model rejects requests over ~1024 input tokens
|
||||
# ("input is too large to process"). Batch by estimated tokens, with a
|
||||
# safety margin under that cap — code-dense text can tokenize at ~3
|
||||
@@ -168,17 +187,30 @@ class LLMClient:
|
||||
(vec,) = await self.embed([text])
|
||||
return vec
|
||||
|
||||
async def chat_stream(self, messages: list[dict[str, str]]) -> AsyncIterator[str]:
|
||||
"""Stream assistant text deltas from the chat model (PLAN A5/A15).
|
||||
async def chat_stream(
|
||||
self, messages: list[dict[str, str]]
|
||||
) -> AsyncIterator[StreamPiece]:
|
||||
"""Stream assistant pieces from the chat model (PLAN A5/A15, phase 17).
|
||||
|
||||
``stream=True`` against the OpenAI-compatible endpoint; yields only
|
||||
non-empty ``delta.content`` pieces. Any failure (network, HTTP,
|
||||
malformed stream) surfaces as :class:`LLMError` so the API layer can
|
||||
turn it into an SSE ``error`` event instead of a hung request.
|
||||
``stream=True`` against the OpenAI-compatible endpoint, yielding
|
||||
typed :class:`StreamPiece` values. Wire convention (verified live
|
||||
against aipi's ``turbo`` on 2026-08-23): the model's reasoning
|
||||
arrives as ``delta.reasoning_content`` chunks (deepseek/litellm
|
||||
convention) **before** the first ``delta.content`` chunk, so in
|
||||
practice thinking pieces precede content pieces. The ``openai``
|
||||
SDK keeps unknown delta fields in ``model_extra``, so ``getattr``
|
||||
is the right accessor — no raw-HTTP parsing is needed. A chunk
|
||||
carrying both fields yields the thinking piece **first**.
|
||||
|
||||
Answers are allowed up to ``BOR_MAX_OUTPUT_TOKENS`` (default 32 768)
|
||||
output tokens — the old hard 700-token cap cut long answers off
|
||||
mid-sentence (owner report 2026-08-22).
|
||||
Reasoning counts against ``max_tokens``: an answer can in principle
|
||||
be empty (thinking with no content) — the UI handles that.
|
||||
Answers are allowed up to ``BOR_MAX_OUTPUT_TOKENS`` (default
|
||||
32 768) output tokens — the old hard 700-token cap cut long
|
||||
answers off mid-sentence (owner report 2026-08-22).
|
||||
|
||||
Any failure (network, HTTP, malformed stream) surfaces as
|
||||
:class:`LLMError` so the API layer can turn it into an SSE
|
||||
``error`` event instead of a hung request.
|
||||
"""
|
||||
try:
|
||||
# ``{role, content}`` dicts are exactly what the message params
|
||||
@@ -193,9 +225,17 @@ class LLMClient:
|
||||
async for chunk in stream:
|
||||
if not chunk.choices:
|
||||
continue
|
||||
piece = chunk.choices[0].delta.content
|
||||
if piece:
|
||||
yield piece
|
||||
delta = chunk.choices[0].delta
|
||||
reasoning = getattr(delta, "reasoning_content", None)
|
||||
if not reasoning:
|
||||
# Future-proofing: the same wire convention under a
|
||||
# shorter field name.
|
||||
reasoning = getattr(delta, "reasoning", None)
|
||||
if reasoning:
|
||||
yield StreamPiece("thinking", reasoning)
|
||||
content = delta.content
|
||||
if content:
|
||||
yield StreamPiece("content", content)
|
||||
except LLMError:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 — wrap transport-level failures
|
||||
|
||||
@@ -45,6 +45,21 @@ class SourceRef(BaseModel):
|
||||
title: str
|
||||
|
||||
|
||||
class ChatThinkingEvent(BaseModel):
|
||||
"""SSE thinking event: one chunk of the model's reasoning (phase 17).
|
||||
|
||||
PLAN §4 extension (A15, owner permission 2026-08-23): frames of the
|
||||
shape ``{type: "thinking", text: str}`` stream ahead of the
|
||||
``delta`` frames in practice (the model reasons before it answers). The
|
||||
client renders them in a collapsible "Thinking" block; the ``done``
|
||||
event shape is unchanged and thinking text never travels on it.
|
||||
Sibling of :class:`ChatErrorEvent`.
|
||||
"""
|
||||
|
||||
type: str = "thinking"
|
||||
text: str
|
||||
|
||||
|
||||
class ChatDoneEvent(BaseModel):
|
||||
"""Final SSE event of a chat turn: metadata for the finished answer."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user