Search, chat and research over parliamentary speeches and documents
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

226 lines
10 KiB

"""Speculative live-streaming of the tool loop's final answer.
The ReAct tool loop (`ChatService`/`MpChatService`'s ``_run_tool_loop``) can't know,
before a ``generate()`` call returns, whether that call will end in ``tool_calls``
(the loop continues) or in bare ``content`` (this iteration *is* the final answer)
— both are legitimate outcomes of the same call, and a turn can even carry short
narration content before a tool call. So there is no way to decide up front
whether a given iteration is worth streaming live to the user.
``AnswerStreamFilter`` resolves this by buffering: it only forwards content once
it is implausible that the buffered text is a one-line narration blurb ("Jag
söker nu efter..."), and ``run_streaming_iteration`` uses it to speculatively
turn content deltas into ``answer_delta`` SSE events — retracting them with a
single ``answer_delta_retract`` event in the rare case a tool call shows up
after all.
Citation markers (``[src:ID]``) are never forwarded raw: their validity isn't
known until ``parse_and_renumber_citations`` runs on the complete text (it can
drop hallucinated IDs), so a marker must never reach the client, even for a
frame. This module only ever *strips* markers from the live preview — it never
renumbers them; that still happens exactly once, on the complete answer, same
as today.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, Optional
from packages.llm import ChatCompletionMessage
from backend.services.event_logger import log_error, log_event
from backend.services.provenance import _SRC_PATTERN
EventCallback = Callable[[Dict[str, Any]], None]
def strip_citation_markers(text: str) -> str:
"""Remove complete ``[src:ID]``/``[[src:ID]]`` tags from a text segment.
Used only on the live preview stream. The full raw text (markers intact)
keeps accumulating server-side, untouched, for the one real
``parse_and_renumber_citations`` pass on the complete answer.
"""
return _SRC_PATTERN.sub("", text)
class AnswerStreamFilter:
"""Buffers streamed content until it's confidently prose, not narration.
One instance per tool-loop iteration.
``feed()`` is called with each raw content chunk as it arrives; it returns
whatever is now safe to forward live (or ``None``), holding back:
- text below the confidence gate — narration blurbs in this codebase are
capped at 150 chars (see ``mp_chat.py``'s status-text truncation); the
gate sits well clear of that, so a false-positive on unusually long
narration is very unlikely, while a real multi-paragraph answer still
clears it almost immediately;
- a trailing bracket run that could still grow into a ``[src:...]``/
``[[src:...]]`` marker.
Complete markers found in a releasable segment are stripped outright
(never rendered, never renumbered live).
"""
def __init__(self, min_chars: int = 400, soft_min_chars: int = 200) -> None:
self._min_chars = min_chars
self._soft_min_chars = soft_min_chars
self._pending = "" # raw text not yet past the confidence gate
self._buffer = "" # gate-cleared text not yet forwarded (bracket-safety only)
self._armed = False
self.flushed_any = False
def feed(self, text: str) -> Optional[str]:
"""Add a raw content chunk; return text safe to forward live now, or None."""
if not text:
return None
if not self._armed:
self._pending += text
if not self._gate_passes(self._pending):
return None
self._armed = True
self._buffer, self._pending = self._pending, ""
log_event("answer_stream_gate_armed", buffered_chars=len(self._buffer))
else:
self._buffer += text
return self._release_safe_segment()
def flush_remainder(self) -> Optional[str]:
"""Release whatever's still held back.
Call once, after the final chunk of an iteration confirmed to be the
answer (no ``tool_calls``) — safe now that the full text is known, so
the confidence gate no longer applies. Still strips citation markers.
A bracket run left unclosed at this point (e.g. generation was cut
off mid-tag by a token limit) can never complete now — it's dropped
outright rather than ever being released raw, same principle as the
mid-stream case, just with "wait for more" replaced by "there is no
more".
"""
remainder = self._pending + self._buffer
self._pending = ""
self._buffer = ""
if not remainder:
return None
remainder = remainder[: _last_unmatched_bracket_start(remainder)]
if not remainder:
return None
piece = strip_citation_markers(remainder)
if piece:
self.flushed_any = True
return piece or None
def _gate_passes(self, text: str) -> bool:
if len(text) >= self._min_chars:
return True
if len(text) >= self._soft_min_chars:
# Already into a second sentence/clause — a narration blurb is
# always a single short sentence, so more text after the first
# sentence boundary is a strong "this is the real answer" signal.
boundary = _first_sentence_boundary(text)
if boundary is not None and boundary < len(text.rstrip()) - 1:
return True
return False
def _release_safe_segment(self) -> Optional[str]:
"""Release ``self._buffer`` up to the start of any trailing, still-open
bracket run — a citation marker's validity depends on characters that
may not have arrived yet, so it (and its double-bracket partner, if
any) is held back until it either completes or is provably not one."""
safe_end = _last_unmatched_bracket_start(self._buffer)
segment, self._buffer = self._buffer[:safe_end], self._buffer[safe_end:]
if not segment:
return None
piece = strip_citation_markers(segment)
if piece:
self.flushed_any = True
return piece or None
def _first_sentence_boundary(text: str) -> Optional[int]:
for i, ch in enumerate(text):
if ch in ".!?" and i + 1 < len(text) and text[i + 1] in " \n":
return i
return None
def _last_unmatched_bracket_start(text: str) -> int:
"""Index from which the text might still be an incomplete bracket marker.
Compares the position of the last ``[`` against the last ``]``: if the
last ``[`` comes after the last ``]`` (or there's no ``]`` at all), that
bracket run is still open — hold from there. Also grabs an immediately
preceding ``[`` so a ``[[src:...`` double-bracket run isn't split, leaving
an orphaned single ``[`` on screen while its partner is held back.
"""
last_open = text.rfind("[")
if last_open == -1:
return len(text)
last_close = text.rfind("]")
if last_close > last_open:
return len(text)
cutoff = last_open
if cutoff > 0 and text[cutoff - 1] == "[":
cutoff -= 1
return cutoff
def run_streaming_iteration(
llm,
gen_kwargs: Dict[str, Any],
event_callback: Optional[EventCallback],
iteration: Optional[int] = None,
) -> ChatCompletionMessage:
"""Run one tool-loop iteration with ``stream=True``, speculatively
forwarding the final answer live as ``answer_delta`` SSE events.
Returns a ``ChatCompletionMessage`` shape-compatible with what a blocking
``generate()`` call returns — the tool loop's existing branching logic
(tool execution, citation-hallucination retry, empty-response handling)
needs no changes to consume it; it's a drop-in replacement for the
blocking call at the single call site in ``_run_tool_loop``.
Raises on API/stream failure — mirrors the blocking path's error-string
contract (``isinstance(response, str)``) being converted to a raised
exception, so a mid-stream failure surfaces the same way: as an SSE
``error`` event, via ``stream_chat_response``'s existing exception
handling.
"""
accumulator = llm.generate(**gen_kwargs, stream=True)
if isinstance(accumulator, str):
# generate() caught the error before the stream ever started.
exc = RuntimeError(f"LLM API error: {accumulator}")
log_error("llm_api_failure", exc, model=getattr(llm, "model", None), iteration=iteration)
raise exc
answer_filter = AnswerStreamFilter()
retracted = False
try:
for kind, text in accumulator:
if kind == "content":
piece = answer_filter.feed(text)
if piece and event_callback:
event_callback({"type": "answer_delta", "text": piece})
elif kind == "tool_call":
# The model has started emitting a tool call — whatever content
# came before was narration, not the final answer. If any of it
# already cleared the confidence gate and reached the client,
# tell it to discard the speculative preview.
if answer_filter.flushed_any and not retracted and event_callback:
event_callback({"type": "answer_delta_retract"})
retracted = True
# "thinking" deltas aren't surfaced to the SSE consumer today,
# matching the blocking path (reasoning_content is only logged).
except Exception as exc:
wrapped = RuntimeError(f"LLM API error: {exc}")
log_error("llm_api_failure", wrapped, model=getattr(llm, "model", None), iteration=iteration)
raise wrapped from exc
message = accumulator.message
if not message.tool_calls:
# Confirmed: this iteration is the final answer. Release anything
# still held back — the confidence gate no longer matters once the
# full text is known.
piece = answer_filter.flush_remainder()
if piece and event_callback:
event_callback({"type": "answer_delta", "text": piece})
return message