feat(chat): stream model thinking over SSE and show it in a collapsible block
This commit is contained in:
+65
-7
@@ -15,6 +15,9 @@ Implements just enough of the aipi surface:
|
||||
- otherwise -> upbeat answer quoting the provided document context
|
||||
- user message containing ``pretend to think slowly`` -> 3s warm-up delay
|
||||
(used by the loading-feedback story).
|
||||
- user message containing ``think out loud`` -> the answer is preceded by
|
||||
~800 chars of deterministic ``reasoning_content`` chunks (the
|
||||
thinking-display story, phase 17).
|
||||
- system prompt containing ``<tuning>`` (phase 15, steering notes) ->
|
||||
the composed answer ends with `` (tuning: <first note line>)`` —
|
||||
makes prompt injection observable in the UI deterministically.
|
||||
@@ -79,6 +82,13 @@ LONG_ANSWER_TRIGGER = "write a long answer"
|
||||
LONG_ANSWER_LINES = 40
|
||||
LONG_ANSWER_END = "LONG-ANSWER-END"
|
||||
|
||||
#: Phase 17 (thinking-display story): a user message containing this
|
||||
#: substring (case-insensitive) is answered with a deterministic
|
||||
#: ``reasoning_content`` stream ahead of the content — same convention as
|
||||
#: the other user-message triggers above. Existing E2E questions do not
|
||||
#: contain the substring, so every other suite is unaffected.
|
||||
THINKING_TRIGGER = "think out loud"
|
||||
|
||||
|
||||
def long_answer() -> str:
|
||||
"""~900-word deterministic walkthrough (phase 11): numbered steps plus
|
||||
@@ -142,6 +152,32 @@ def compose_answer(body: dict[str, Any]) -> str:
|
||||
return answer
|
||||
|
||||
|
||||
def compose_thinking(body: dict[str, Any]) -> str:
|
||||
"""Deterministic reasoning scratchpad (thinking-display story, phase 17).
|
||||
|
||||
A fixed 4-line "Step 1… Step 4" template quoting the first ~60 chars
|
||||
of the user question: unique per question, byte-stable across runs,
|
||||
~700–900 chars total (≈ 60–75 frames at the mock's 12-char/0.02s
|
||||
pacing). The ``Step 2: Check my notes`` line fragment is what the E2E
|
||||
assertions key off.
|
||||
"""
|
||||
q = _user(body).strip()[:60]
|
||||
return (
|
||||
f"Step 1: Read the question carefully — “{q}” — and figure out what kind of "
|
||||
"answer it wants (a how-to, a lookup, or a design decision) before touching "
|
||||
"the docs, so I don't over- or under-answer.\n"
|
||||
"Step 2: Check my notes for the closest match. The homelab kubernetes file "
|
||||
"is the obvious candidate, but I should also consider whether a deployments "
|
||||
"note covers the same ground better.\n"
|
||||
"Step 3: Re-read the relevant sections top to bottom so every specific — "
|
||||
"hosts, versions, ports, schedules — is exact as written rather than "
|
||||
"remembered, and note which document each fact comes from.\n"
|
||||
"Step 4: Draft the answer around those specifics, keep it tight with short "
|
||||
"paragraphs and bullets where it helps, cite the documents by path, and "
|
||||
"double-check that nothing is invented."
|
||||
)
|
||||
|
||||
|
||||
@app.post("/__shutdown__")
|
||||
def shutdown() -> dict[str, Any]:
|
||||
"""Test hook (loading-feedback story): terminate this mock process to
|
||||
@@ -186,11 +222,31 @@ def embeddings(body: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _sse_stream(answer: str, delay: float) -> Any:
|
||||
def _sse_stream(answer: str, delay: float, thinking: str = "") -> Any:
|
||||
"""SSE frames for one chat completion (phase 17: + reasoning).
|
||||
|
||||
When ``thinking`` is non-empty its 12-char slices go out FIRST as
|
||||
``delta.reasoning_content`` frames — same 0.02s cadence and envelope
|
||||
as the content frames, the aipi wire convention (reasoning before
|
||||
content). Without ``thinking`` the output is byte-identical to the
|
||||
content-only stream, so the other story suites are unaffected.
|
||||
"""
|
||||
model = "turbo"
|
||||
chunk_id = f"chatcmpl-{uuid.uuid4()}"
|
||||
if delay:
|
||||
time.sleep(delay)
|
||||
for piece in re.findall(r".{1,12}", thinking, re.S):
|
||||
payload = {
|
||||
"id": chunk_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": int(time.time()),
|
||||
"model": model,
|
||||
"choices": [
|
||||
{"index": 0, "delta": {"reasoning_content": piece}, "finish_reason": None}
|
||||
],
|
||||
}
|
||||
yield f"data: {json_dumps(payload)}\n\n"
|
||||
time.sleep(0.02)
|
||||
for piece in re.findall(r".{1,12}", answer, re.S):
|
||||
payload = {
|
||||
"id": chunk_id,
|
||||
@@ -239,25 +295,27 @@ def _apply_max_tokens(answer: str, max_tokens: Any) -> str:
|
||||
def chat_completions(body: dict[str, Any]) -> Any:
|
||||
answer = _apply_max_tokens(compose_answer(body), body.get("max_tokens"))
|
||||
delay = 3.0 if "pretend to think slowly" in _user(body) else 0.0
|
||||
thinking = compose_thinking(body) if THINKING_TRIGGER in _user(body).lower() else ""
|
||||
|
||||
if not body.get("stream"):
|
||||
message: dict[str, Any] = {"role": "assistant", "content": answer}
|
||||
if thinking:
|
||||
# Harmless future-proofing: the app only uses streaming, but a
|
||||
# non-streaming client that reads the field gets the reasoning.
|
||||
message["reasoning_content"] = thinking
|
||||
return {
|
||||
"id": f"chatcmpl-{uuid.uuid4()}",
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": body.get("model", "turbo"),
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": answer},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
{"index": 0, "message": message, "finish_reason": "stop"}
|
||||
],
|
||||
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
|
||||
}
|
||||
|
||||
return StreamingResponse(
|
||||
_sse_stream(answer, delay),
|
||||
_sse_stream(answer, delay, thinking=thinking),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user