feat(rag): pass chat history with prior thinking to the LLM
Phase 74 (TODO.md L4): a follow-up question now reaches the model WITH the conversation so far — every prior user/brain turn and the prior thinking blocks on brain turns (preserve-thinking) — while POST /api/chat stays stateless (A10): the client provides the history in the request body and the server stores nothing new. Server (task 01): - ChatRequest.history: optional list[HistoryTurn] (who: user|brain, text, optional thinking) — absent/empty keeps the request byte-identical to pre-phase-74 (the two-message [system, user] request; the kill-switch semantics are pinned in the integration suite). - app.rag.prompts.history_to_messages: pure mapper — walks the turns newest-first against the settings budgets (history_max_turns=40 / history_max_chars=24000, BOR_HISTORY_MAX_TURNS / BOR_HISTORY_MAX_CHARS); a capped turn is dropped WHOLE (never cut mid-answer); the kept window is returned oldest-first; brain turns carry their thinking as reasoning_content (A4) only when non-empty. - Both branches feed it: the deflected path splices it between the system prompt and the current user message (the phase-71 recovery still rebuilds from messages[1:]), the grounded agent receives run_agent(..., history=hist); llm.py's message params widen to list[dict[str, Any]] (string-only messages stay byte-identical on the wire — the SDK passes message dicts through verbatim). - The per-turn log line (PLAN §9) gains history_msgs=N after kb_chars=N. - Pins: tests/unit/test_history.py (mapper: mapping, reasoning gating, both budgets, drop-whole, ordering, empty default), tests/unit/test_config.py (the two settings + env overrides), tests/unit/test_agent.py (the history splice + the default), tests/integration/test_chat_api.py (deflected AND grounded forward the history incl. reasoning_content, no-history byte-identity, 422 pins, the log field). Client (task 02): - runTurn — the single funnel for fresh send / phase-49 retry / phase-53 stale-regen — sends history = the conversation record minus the current question, with thinking only on brain records that streamed one (undefined drops the key from the JSON, the record's convention); the question is never duplicated into the history. Wire proof (task 03): - The mock's echo my history marker (HISTORY_TRIGGER) answers with the deterministic history echo — history: N prior messages; last answer tail: <last 24 chars>; thinking: yes|no — checked BEFORE the DEFLECT_MODE branch (like TABLE_TRIGGER), so it fires on both turn branches whatever the gate says; the module docstring records the user/assistant-only history invariant that keeps every existing (tool-result-classified) marker flow unaffected. - tests/e2e/test_llm_history.py (isolated): a grounded follow-up and a deflected follow-up both receive history: 2 prior messages + thinking: yes + the byte-exact tail of turn 1's answer (derived from the persisted bor.chat.v1 record — the same array the client maps into the body); a cold start receives history: 0 prior messages / last answer tail: none / thinking: no. - Regressions green in isolation: chat_rag, chat_history (phase 50), agent_document_tools, harness_aligned_tools, stop_generation, retry_answer, response_to_docs.
This commit is contained in:
+32
-4
@@ -115,6 +115,23 @@ recovery, via the holder; deflected: this turn's filters), 0 on clean
|
||||
turns (the field is uniform, the phase-67 ``retries=N`` pattern); the
|
||||
recovery does not bump ``retries=N`` (it is not a phase-67
|
||||
endpoint-retry).
|
||||
|
||||
Chat history (phase 74, TODO L4, owner-locked A2/A3/A4 2026-09-08):
|
||||
``POST /api/chat`` accepts an optional ``history`` — the client's prior
|
||||
turns, oldest first (the ``bor.chat.v1`` record minus the current
|
||||
question; the endpoint stays stateless per A10 — nothing is stored). It
|
||||
is mapped ONCE per turn by :func:`app.rag.prompts.history_to_messages`
|
||||
— trimmed newest-first against the settings budgets
|
||||
(``history_max_turns`` / ``history_max_chars``; a capped-out turn is
|
||||
dropped whole, never truncated) — and fed to the model on BOTH turn
|
||||
branches: the deflected path splices it between the system prompt and
|
||||
the current user message (the phase-71 recovery still rebuilds from
|
||||
``messages[1:]`` — unchanged), and the grounded agent receives it as
|
||||
``run_agent(..., history=hist)``. Prior brain turns' thinking travels
|
||||
as ``reasoning_content`` on the assistant message (the preserve-
|
||||
thinking wire convention, A4). The per-turn log line records
|
||||
``history_msgs=N`` after ``kb_chars=N`` (0 when the request carries no
|
||||
history — the two-message request stays byte-identical).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -150,7 +167,7 @@ from app.rag.llm import (
|
||||
chat_stream_retried, # phase 67: the retry-before-first-piece primitive
|
||||
)
|
||||
from app.rag.overview import load_kb_overview
|
||||
from app.rag.prompts import build_deflect_prompt, build_high_prompt
|
||||
from app.rag.prompts import build_deflect_prompt, build_high_prompt, history_to_messages
|
||||
from app.rag.retriever import RetrievedChunk, retrieve, select_documents, weak_hit_titles
|
||||
from app.rag.scaffolding import ScaffoldingFilter # phase 71: the streaming filter
|
||||
from app.rag.suggestions import derive_suggestions
|
||||
@@ -306,6 +323,14 @@ async def chat(
|
||||
retries_used = 0 # phase 67: LLM requests restarted this turn (log line)
|
||||
try:
|
||||
settings = get_settings()
|
||||
# Phase 74 (TODO L4): the client's prior turns, mapped ONCE
|
||||
# per turn — trimmed newest-first against the settings
|
||||
# budgets, assistant turns carrying their prior thinking as
|
||||
# ``reasoning_content`` (A4). BOTH branches below (deflected
|
||||
# + grounded agent) reuse the same block; an absent/empty
|
||||
# history yields ``[]`` (the byte-identical two-message
|
||||
# request, A2).
|
||||
hist = history_to_messages(request.history, settings)
|
||||
|
||||
# 1. Embed the question.
|
||||
# Phase 67: a dead embeddings endpoint is retried before any
|
||||
@@ -384,8 +409,9 @@ async def chat(
|
||||
).model_dump()
|
||||
)
|
||||
return
|
||||
messages = [
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": plan.system_prompt},
|
||||
*hist, # phase 74: the trimmed prior turns (empty by default)
|
||||
{"role": "user", "content": request.message},
|
||||
]
|
||||
|
||||
@@ -434,6 +460,7 @@ async def chat(
|
||||
seed_docs=plan.docs,
|
||||
settings=settings,
|
||||
holder=holder,
|
||||
history=hist, # phase 74: the same trimmed prior turns
|
||||
)
|
||||
thinking_chars = 0
|
||||
content_chars = 0 # phase 71: the turn's visible (clean) content
|
||||
@@ -636,8 +663,8 @@ async def chat(
|
||||
|
||||
logger.info(
|
||||
"question=%r embed_ms=%d top_score=%.3f fts_hits=%d summary_hits=%d tuning=%d "
|
||||
"kb_chars=%d threshold=%.2f deflected=%s sources=%r thinking_chars=%d "
|
||||
"tool_calls=%d total_ms=%d retries=%d scaffold_stripped=%d",
|
||||
"kb_chars=%d history_msgs=%d threshold=%.2f deflected=%s sources=%r "
|
||||
"thinking_chars=%d tool_calls=%d total_ms=%d retries=%d scaffold_stripped=%d",
|
||||
request.message,
|
||||
embed_ms,
|
||||
plan.top_score,
|
||||
@@ -645,6 +672,7 @@ async def chat(
|
||||
plan.summary_hits,
|
||||
plan.tuning_count,
|
||||
plan.kb_chars,
|
||||
len(hist),
|
||||
settings.relevance_threshold,
|
||||
plan.deflected,
|
||||
source_paths,
|
||||
|
||||
@@ -80,6 +80,21 @@ class Settings(BaseSettings):
|
||||
#: Flat seconds to wait between attempts (phase 67,
|
||||
#: ``BOR_LLM_RETRY_DELAY``); the TODO-locked 5 s, no backoff.
|
||||
llm_retry_delay: float = 5.0
|
||||
# --- Chat history (phase 74, TODO L4: prior turns + prior thinking) ---
|
||||
#: Newest client-provided history turns kept per ``POST /api/chat``
|
||||
#: (phase 74, ``BOR_HISTORY_MAX_TURNS``): the request's ``history``
|
||||
#: (the client's prior turns, stateless per A10) is walked
|
||||
#: newest-first and the walk stops once this many turns are kept —
|
||||
#: the oldest turns are the ones dropped. ``0`` = no history (the
|
||||
#: pre-phase-74 two-message requests — the kill switch).
|
||||
history_max_turns: int = 40
|
||||
#: Total char budget for the kept history (phase 74,
|
||||
#: ``BOR_HISTORY_MAX_CHARS``) — ``len(text) + len(thinking or "")``
|
||||
#: per turn, so prior thinking blocks count against the same budget
|
||||
#: as the answer text. A turn that would overflow the remaining
|
||||
#: budget is dropped WHOLE (never cut mid-answer) and the walk stops
|
||||
#: there — the kept history is always a contiguous newest window.
|
||||
history_max_chars: int = 24_000
|
||||
|
||||
# --- RAG tuning ---
|
||||
embedding_dim: int = 768 # verified against aipi /v1 (embed model)
|
||||
@@ -294,6 +309,26 @@ class Settings(BaseSettings):
|
||||
raise ValueError("upload_max_mb must be > 0 (MiB)")
|
||||
return v
|
||||
|
||||
@field_validator("history_max_turns")
|
||||
@classmethod
|
||||
def _history_max_turns_non_negative(cls, v: int) -> int:
|
||||
"""``0`` is the no-history kill switch (pre-phase-74 two-message
|
||||
requests) — a negative value is a typo (the ``agent_max_rounds``
|
||||
pattern)."""
|
||||
if v < 0:
|
||||
raise ValueError("history_max_turns must be >= 0 (0 = no history)")
|
||||
return v
|
||||
|
||||
@field_validator("history_max_chars")
|
||||
@classmethod
|
||||
def _history_max_chars_non_negative(cls, v: int) -> int:
|
||||
"""``0`` is the no-history kill switch (pre-phase-74 two-message
|
||||
requests) — a negative value is a typo (the ``agent_max_rounds``
|
||||
pattern)."""
|
||||
if v < 0:
|
||||
raise ValueError("history_max_chars must be >= 0 (chars)")
|
||||
return v
|
||||
|
||||
@field_validator("docs_branch", "docs_base_branch")
|
||||
@classmethod
|
||||
def _docs_branch_tokens(cls, v: str, info: ValidationInfo) -> str:
|
||||
|
||||
+18
-4
@@ -176,7 +176,7 @@ import logging
|
||||
import re
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -821,6 +821,7 @@ async def run_agent(
|
||||
seed_docs: Sequence[Document],
|
||||
settings: Settings,
|
||||
holder: AgentHolder,
|
||||
history: Sequence[dict[str, Any]] = (),
|
||||
) -> AsyncIterator[StreamPiece | ToolCallPiece | RetryPiece]:
|
||||
"""Run the grounded-turn tool loop, yielding every stream piece.
|
||||
|
||||
@@ -830,6 +831,18 @@ async def run_agent(
|
||||
SSE ``retry`` events. After the loop finishes, *holder* carries the
|
||||
read documents and the executed tool-call count (re-lists included).
|
||||
|
||||
History (phase 74, TODO L4): *history* is the client's prior turns
|
||||
already mapped to model messages by
|
||||
:func:`app.rag.prompts.history_to_messages` (trimmed newest-first
|
||||
against the settings budgets; assistant turns carry their prior
|
||||
thinking as ``reasoning_content``). It is spliced between the system
|
||||
prompt and the current user message —
|
||||
``[system, *history, user]`` — and everything downstream (the tool
|
||||
rounds, the phase-71 recovery rebuilding from ``messages[1:]``, the
|
||||
retry restarts) already operates on that one ``messages`` list,
|
||||
unchanged. ``()`` (the default) keeps the pre-phase-74 two-message
|
||||
request byte-identical.
|
||||
|
||||
Retries (phase 67, owner-locked A2): every model request goes through
|
||||
:func:`chat_stream_retried` — a failed round is retried **before** its
|
||||
first piece (same messages, ``settings.llm_retries`` restarts, a flat
|
||||
@@ -862,6 +875,7 @@ async def run_agent(
|
||||
"""
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
*history, # phase 74: the client's prior turns (empty by default)
|
||||
{"role": "user", "content": user_message},
|
||||
]
|
||||
# Phase 45: no per-tool budgets — the tools stay offered for the
|
||||
@@ -892,7 +906,7 @@ async def run_agent(
|
||||
# a quiet no-op.
|
||||
stream = chat_stream_retried(
|
||||
llm,
|
||||
cast("list[dict[str, str]]", messages),
|
||||
messages,
|
||||
tools=tools,
|
||||
retries=settings.llm_retries,
|
||||
delay=settings.llm_retry_delay,
|
||||
@@ -948,7 +962,7 @@ async def run_agent(
|
||||
recovery_filter = ScaffoldingFilter()
|
||||
recovered = chat_stream_retried(
|
||||
llm,
|
||||
cast("list[dict[str, str]]", messages_recovered),
|
||||
messages_recovered,
|
||||
tools=None,
|
||||
retries=settings.llm_retries,
|
||||
delay=settings.llm_retry_delay,
|
||||
@@ -1030,7 +1044,7 @@ async def run_agent(
|
||||
final_filter = ScaffoldingFilter()
|
||||
final = chat_stream_retried(
|
||||
llm,
|
||||
cast("list[dict[str, str]]", messages),
|
||||
messages,
|
||||
tools=None,
|
||||
retries=settings.llm_retries,
|
||||
delay=settings.llm_retry_delay,
|
||||
|
||||
+11
-3
@@ -298,7 +298,7 @@ class LLMClient:
|
||||
return vec
|
||||
|
||||
async def chat(
|
||||
self, messages: list[dict[str, str]], model: str | None = None
|
||||
self, messages: list[dict[str, Any]], model: str | None = None
|
||||
) -> str:
|
||||
"""One-shot (non-streaming) completion (A5 extended, phase 30).
|
||||
|
||||
@@ -341,12 +341,20 @@ class LLMClient:
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
scaffolding: ScaffoldingFilter | None = None,
|
||||
) -> AsyncGenerator[StreamPiece | ToolCallPiece, None]:
|
||||
"""Stream assistant pieces from the chat model (PLAN A5/A15, phase 17).
|
||||
|
||||
Messages are passed to the request body VERBATIM: string-only
|
||||
``{role, content}`` dicts are byte-identical on the wire to the
|
||||
pre-phase-74 requests, and an assistant message may additionally
|
||||
carry ``reasoning_content`` (the client's prior thinking, phase
|
||||
74 — the same wire field the model uses for its OWN reasoning on
|
||||
the response side; the ``openai`` SDK passes message dicts
|
||||
through untouched, so no transport change).
|
||||
|
||||
``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
|
||||
@@ -488,7 +496,7 @@ class LLMClient:
|
||||
|
||||
async def chat_stream_retried(
|
||||
llm: LLMClient,
|
||||
messages: list[dict[str, str]],
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
retries: int = 0,
|
||||
|
||||
+59
-1
@@ -54,10 +54,12 @@ not the wording, so that contract is unchanged.
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from app.config import get_settings
|
||||
from app.config import Settings, get_settings
|
||||
from app.models import Document
|
||||
from app.rag.retriever import TRUNCATION_MARKER
|
||||
from app.schemas import HistoryTurn
|
||||
|
||||
#: PLAN §6 verbatim (line wrapping included); ``{relevance}`` is filled by
|
||||
#: :func:`_base`.
|
||||
@@ -166,6 +168,62 @@ TOOLS_SECTION: str = (
|
||||
)
|
||||
|
||||
|
||||
def history_to_messages(
|
||||
history: Sequence[HistoryTurn],
|
||||
settings: Settings,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Client-provided chat history → model messages (phase 74, TODO L4).
|
||||
|
||||
The ``POST /api/chat`` ``history`` (the client's prior turns, oldest
|
||||
first) becomes the message block that sits between the system prompt
|
||||
and the current user message — so a follow-up question reaches the
|
||||
model together with the exchange so far, on BOTH turn branches
|
||||
(the deflected path and the grounded agent).
|
||||
|
||||
Trimming (owner-locked A3, 2026-09-08): the turns are walked
|
||||
**newest-first** and kept while BOTH budgets hold — the turn count
|
||||
stays ≤ ``settings.history_max_turns`` and the cumulative chars
|
||||
(``len(text) + len(thinking or "")`` per turn) stay ≤
|
||||
``settings.history_max_chars``. A turn that would overflow either
|
||||
remaining budget is DROPPED WHOLE — never cut mid-answer — and the
|
||||
walk stops there, so the kept history is always the contiguous
|
||||
newest window (the oldest turns are the ones dropped; ``0`` on
|
||||
either budget yields ``[]`` — the pre-phase-74 behavior). The kept
|
||||
turns are returned in chronological (oldest → newest) order.
|
||||
|
||||
Mapping (owner-locked A4, 2026-09-08): ``who="user"`` →
|
||||
``{"role": "user", "content": text}``; ``who="brain"`` →
|
||||
``{"role": "assistant", "content": text}`` plus
|
||||
``"reasoning_content": thinking`` ONLY when *thinking* is
|
||||
non-empty — the preserve-thinking wire convention
|
||||
:mod:`app.rag.llm` already reads on the response side
|
||||
(``delta.reasoning_content``), which is what keeps the owner's
|
||||
preserve-thinking models carrying the reasoning chain forward.
|
||||
|
||||
Pure and side-effect free (no I/O) — unit-testable in isolation.
|
||||
"""
|
||||
kept: list[HistoryTurn] = []
|
||||
chars = 0
|
||||
for turn in reversed(history):
|
||||
if len(kept) >= settings.history_max_turns:
|
||||
break
|
||||
size = len(turn.text) + len(turn.thinking or "")
|
||||
if chars + size > settings.history_max_chars:
|
||||
break
|
||||
kept.append(turn)
|
||||
chars += size
|
||||
messages: list[dict[str, Any]] = []
|
||||
for turn in reversed(kept):
|
||||
if turn.who == "user":
|
||||
messages.append({"role": "user", "content": turn.text})
|
||||
continue
|
||||
message: dict[str, Any] = {"role": "assistant", "content": turn.text}
|
||||
if turn.thinking:
|
||||
message["reasoning_content"] = turn.thinking
|
||||
messages.append(message)
|
||||
return messages
|
||||
|
||||
|
||||
def _base(relevance: str) -> str:
|
||||
if relevance not in ("HIGH", "LOW"):
|
||||
raise ValueError(f"relevance must be HIGH or LOW, got {relevance!r}")
|
||||
|
||||
+43
-2
@@ -26,9 +26,50 @@ class SuggestionList(BaseModel):
|
||||
suggestions: list[str]
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str = Field(min_length=1, max_length=4000)
|
||||
class HistoryTurn(BaseModel):
|
||||
"""One prior chat turn the client sends with ``POST /api/chat``
|
||||
(phase 74, TODO L4).
|
||||
|
||||
The endpoint stays stateless (A10): the client's ``bor.chat.v1``
|
||||
conversation record (minus the question about to be asked) is
|
||||
provided in the request body as ``history`` so a follow-up question
|
||||
reaches the model together with the exchange so far — and, for
|
||||
preserve-thinking models, with the prior brain turns' thinking (the
|
||||
record has carried the ``thinking`` key since phase 17).
|
||||
|
||||
``thinking`` travels to the model as ``reasoning_content`` on the
|
||||
assistant message (the wire convention :mod:`app.rag.llm` already
|
||||
documents for the response side) — only when non-empty (A4).
|
||||
``text`` mirrors :attr:`ChatMessage.text`'s answer shape; the
|
||||
thinking cap is looser (scratchpads run longer than answers). These
|
||||
are boundary sanity caps only — the real trimming budget is the
|
||||
settings pair ``history_max_turns`` / ``history_max_chars``
|
||||
(``app.config``, A3: a capped-out turn is dropped whole, never
|
||||
truncated).
|
||||
"""
|
||||
|
||||
who: Literal["user", "brain"]
|
||||
text: str = Field(min_length=1, max_length=4000)
|
||||
thinking: str | None = Field(default=None, max_length=32000)
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
"""``POST /api/chat`` body: the current question plus the optional
|
||||
prior turns (phase 74 — the client-provided history, stateless per
|
||||
A10).
|
||||
|
||||
``history`` is the client's earlier turns, oldest first (the
|
||||
``bor.chat.v1`` record minus the current question); the mapper
|
||||
(:func:`app.rag.prompts.history_to_messages`) trims it newest-first
|
||||
against the settings budgets and maps it to model messages. The
|
||||
schema-level ``max_length=100`` is a DoS sanity ceiling only — the
|
||||
config budgets do the real trimming (A3). Absent or empty keeps the
|
||||
request byte-identical to pre-phase-74: the model sees exactly the
|
||||
two-message ``[system, user]`` request.
|
||||
"""
|
||||
|
||||
message: str = Field(min_length=1, max_length=4000)
|
||||
history: list[HistoryTurn] = Field(default_factory=list, max_length=100)
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""``POST /api/login`` body (phase 16): the single admin's password.
|
||||
|
||||
Reference in New Issue
Block a user