Skip to content

Commit 48cc8da

Browse files
authored
Merge branch 'llm-487' into wip
2 parents 3daaa07 + 00ccbea commit 48cc8da

20 files changed

Lines changed: 3797 additions & 129 deletions

docs/CONTEXT_WORKFLOW_GREETING_DETECTION.md

Lines changed: 187 additions & 50 deletions
Large diffs are not rendered by default.

src/llm_orchestration_service.py

Lines changed: 111 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@
6060
from src.utils.language_detector import detect_language, get_language_name
6161
from src.utils.prompt_config_loader import PromptConfigurationLoader
6262
from src.utils.query_validator import validate_query_basic
63+
from src.utils.sse_utils import extract_content_from_sse
64+
from src.utils.conversation_history_store import should_save_history, save_history_round
65+
from src.utils.conversation_history_helpers import get_conversation_history
6366
from src.guardrails import NeMoRailsAdapter, GuardrailCheckResult
6467
from src.contextual_retrieval import ContextualRetriever
6568
from src.contextual_retrieval.bm25_search import SmartBM25Search
@@ -68,13 +71,27 @@
6871
ContextualRetrievalFailureError,
6972
)
7073
from src.llm_orchestrator_config.feature_flags import FeatureFlags
71-
from src.tool_classifier import ToolClassifier
74+
from src.tool_classifier import ToolClassifier, WorkflowType
7275
from src.tool_classifier.constants import SERVICE_STEP_PREFIXES
7376
from src.tool_classifier.workflows.service_workflow import ServiceWorkflowExecutor
7477

7578
# Initialize Loki logger for orchestration service
7679
logger = LokiLogger(service_name="llm-orchestration-service")
7780

81+
# Set of content strings that must NOT be persisted in conversation history.
82+
# Covers all multilingual error / OOS / guardrail-violation messages so that
83+
# failed or blocked exchanges are never written to Redis.
84+
_HISTORY_EXCLUDED_MESSAGES: frozenset[str] = frozenset(
85+
{
86+
*OUT_OF_SCOPE_MESSAGES.values(),
87+
*TECHNICAL_ISSUE_MESSAGES.values(),
88+
*INPUT_GUARDRAIL_VIOLATION_MESSAGES.values(),
89+
*OUTPUT_GUARDRAIL_VIOLATION_MESSAGES.values(),
90+
*QUERY_VALIDATION_FAILED_MESSAGES.values(),
91+
STREAM_TOKEN_LIMIT_MESSAGE,
92+
}
93+
)
94+
7895

7996
class LangfuseConfig:
8097
"""Configuration for Langfuse integration."""
@@ -152,6 +169,11 @@ def __init__(self) -> None:
152169
# Workflow executors access it via self.orchestration_service.session_store.
153170
self.session_store: Any = None
154171

172+
# Redis-backed conversation history store.
173+
# Set to None here; the FastAPI lifespan injects the live store after
174+
# Redis initialises (app.state.orchestration_service.conversation_history_store = ...).
175+
self.conversation_history_store: Any = None
176+
155177
# Shared BM25 search index pre-warmed at startup.
156178
# Populated by _prewarm_shared_bm25() which is called from the FastAPI
157179
# lifespan so it runs inside the async event loop. Until then it is None
@@ -503,6 +525,18 @@ async def process_orchestration_request(
503525
},
504526
)
505527
langfuse.flush()
528+
529+
# Persist successful exchange to conversation history (non-streaming)
530+
if should_save_history(
531+
self.conversation_history_store, response, _HISTORY_EXCLUDED_MESSAGES
532+
):
533+
await save_history_round(
534+
self.conversation_history_store,
535+
request.chatId,
536+
request.message,
537+
response.content,
538+
)
539+
506540
return response
507541

508542
except Exception as e:
@@ -731,14 +765,51 @@ async def stream_orchestration_response(
731765
)
732766
time_metric["classifier.route"] = time.time() - start_time
733767

768+
# Accumulate content for history only on non-RAG workflows;
769+
# RAG routes through _stream_rag_pipeline which has its own hook.
770+
_save_classifier_history = (
771+
self.conversation_history_store is not None
772+
and classification.workflow != WorkflowType.RAG
773+
)
774+
_classifier_accumulated: list[str] = []
775+
# Tracks whether an excluded marker (OOS / guardrail violation /
776+
# error) was observed at any point during the stream. When True
777+
# the entire accumulated buffer is discarded so no partial content
778+
# from before the blocked marker is ever written to Redis.
779+
_history_blocked = False
780+
734781
async for sse_chunk in stream_result:
735782
yield sse_chunk
783+
if _save_classifier_history and not _history_blocked:
784+
extracted = extract_content_from_sse(sse_chunk)
785+
if extracted is not None and extracted != "END":
786+
if extracted in _HISTORY_EXCLUDED_MESSAGES:
787+
# Excluded marker observed — discard any partial
788+
# content accumulated before this point and stop
789+
# accumulating for the rest of the stream.
790+
_classifier_accumulated.clear()
791+
_history_blocked = True
792+
else:
793+
_classifier_accumulated.append(extracted)
736794

737795
# Successfully completed streaming through classifier
738796
logger.info(
739797
f"[{request.chatId}] [{stream_ctx.stream_id}] Tool classifier streaming completed"
740798
)
741799

800+
# Persist conversation history (classifier streaming, non-RAG workflows)
801+
if (
802+
_save_classifier_history
803+
and not _history_blocked
804+
and _classifier_accumulated
805+
):
806+
await save_history_round(
807+
self.conversation_history_store,
808+
request.chatId,
809+
request.message,
810+
"".join(_classifier_accumulated),
811+
)
812+
742813
# Log costs and timings
743814
self.log_costs(costs_metric)
744815
log_step_timings(time_metric, request.chatId)
@@ -868,10 +939,16 @@ async def _stream_rag_pipeline(
868939
)
869940

870941
start_time = time.time()
942+
conversation_history, conversation_summary = await get_conversation_history(
943+
chat_id=request.chatId,
944+
store=self.conversation_history_store,
945+
fallback=request.conversationHistory,
946+
)
871947
refined_output, refiner_usage = self._refine_user_prompt(
872948
llm_manager=components["llm_manager"],
873949
original_message=request.message,
874-
conversation_history=request.conversationHistory,
950+
conversation_history=conversation_history,
951+
conversation_summary=conversation_summary,
875952
)
876953
time_metric["prompt_refiner"] = time.time() - start_time
877954
costs_metric["prompt_refiner"] = refiner_usage
@@ -1173,6 +1250,17 @@ async def bot_response_generator() -> AsyncIterator[str]:
11731250
f"Storage failed for chat_id: {request.chatId}, environment: {request.environment} - {str(storage_error)}"
11741251
)
11751252

1253+
# Persist conversation history (RAG streaming)
1254+
if self.conversation_history_store is not None:
1255+
_rag_bot_message = "".join(accumulated_response)
1256+
if _rag_bot_message not in _HISTORY_EXCLUDED_MESSAGES:
1257+
await save_history_round(
1258+
self.conversation_history_store,
1259+
request.chatId,
1260+
request.message,
1261+
_rag_bot_message,
1262+
)
1263+
11761264
# Mark stream as completed successfully
11771265
stream_ctx.mark_completed()
11781266

@@ -1422,10 +1510,16 @@ async def _execute_orchestration_pipeline(
14221510

14231511
# Step 1: Refine user prompt
14241512
start_time = time.time()
1513+
conversation_history, conversation_summary = await get_conversation_history(
1514+
chat_id=request.chatId,
1515+
store=self.conversation_history_store,
1516+
fallback=request.conversationHistory,
1517+
)
14251518
refined_output, refiner_usage = self._refine_user_prompt(
14261519
llm_manager=components["llm_manager"],
14271520
original_message=request.message,
1428-
conversation_history=request.conversationHistory,
1521+
conversation_history=conversation_history,
1522+
conversation_summary=conversation_summary,
14291523
)
14301524
timing_key = f"{prefix}.prompt_refiner" if prefix else "prompt_refiner"
14311525
time_metric[timing_key] = time.time() - start_time
@@ -2304,6 +2398,7 @@ def _refine_user_prompt(
23042398
llm_manager: LLMManager,
23052399
original_message: str,
23062400
conversation_history: List[ConversationItem],
2401+
conversation_summary: Optional[str] = None,
23072402
) -> tuple[PromptRefinerOutput, Dict[str, Any]]:
23082403
"""
23092404
Refine user prompt using loaded LLM configuration and return usage info.
@@ -2312,6 +2407,10 @@ def _refine_user_prompt(
23122407
llm_manager: The LLM manager instance to use
23132408
original_message: The original user message to refine
23142409
conversation_history: Previous conversation context
2410+
conversation_summary: Optional summary of earlier conversation rounds
2411+
that were evicted from Redis. When provided it is prepended to the
2412+
DSPy history as a ``system`` turn so the refiner can use it for
2413+
context without re-summarising via an LLM call.
23152414
23162415
Returns:
23172416
Tuple of (PromptRefinerOutput, usage_dict): The refined prompt output and usage info
@@ -2324,8 +2423,16 @@ def _refine_user_prompt(
23242423
logger.info("Starting prompt refinement process")
23252424

23262425
try:
2327-
# Convert conversation history to DSPy format
2426+
# Convert conversation history to DSPy format, optionally prepending
2427+
# a pre-computed summary of earlier (evicted) conversation rounds.
23282428
history: List[Dict[str, str]] = []
2429+
if conversation_summary:
2430+
history.append(
2431+
{
2432+
"role": "system",
2433+
"content": f"Summary of earlier conversation: {conversation_summary}",
2434+
}
2435+
)
23292436
for item in conversation_history:
23302437
role = "assistant" if item.authorRole == "bot" else item.authorRole
23312438
history.append({"role": role, "content": item.message})

src/llm_orchestration_service_api.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""LLM Orchestration Service API - FastAPI application."""
22

3+
import asyncio
34
import os
45
import logging
56
from contextlib import asynccontextmanager
@@ -12,12 +13,15 @@
1213
import uvicorn
1314

1415
from llm_orchestration_service import LLMOrchestrationService
16+
from llm_orchestrator_config.llm_manager import LLMManager
1517
from src.utils.redis_client import (
1618
init_redis_client,
1719
close_redis_client,
1820
check_redis_health,
1921
)
2022
from src.utils.api_tool_session_store import APIToolSessionStore
23+
from src.utils.conversation_history_store import ConversationHistoryStore
24+
from src.utils.conversation_summary_generator import create_incremental_summarizer
2125
from src.llm_orchestrator_config.llm_ochestrator_constants import (
2226
STREAMING_ALLOWED_ENVS,
2327
STREAM_TIMEOUT_MESSAGE,
@@ -98,23 +102,50 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
98102
try:
99103
await init_redis_client()
100104
app.state.session_store = APIToolSessionStore()
105+
106+
# Wire an incremental summarizer if the LLM manager singleton is available.
107+
summarizer = None
108+
try:
109+
summarizer = create_incremental_summarizer(LLMManager())
110+
logger.info("Incremental conversation summarizer initialized")
111+
except Exception as e:
112+
logger.warning(
113+
f"Could not create incremental summarizer, continuing without it: {e}"
114+
)
115+
116+
app.state.conversation_history_store = ConversationHistoryStore(
117+
summarizer=summarizer
118+
)
101119
logger.info("Redis session store initialized successfully")
102120
except Exception as e:
103121
logger.warning(f"Redis session store unavailable, continuing without it: {e}")
104122
app.state.session_store = None
123+
app.state.conversation_history_store = None
105124

106-
# Expose session_store on the orchestration service so workflow executors
107-
# (e.g. APIToolWorkflowExecutor) can reach it via self.orchestration_service.
125+
# Expose session_store and conversation_history_store on the orchestration
126+
# service so downstream components can reach them via self.orchestration_service.
108127
if (
109128
hasattr(app.state, "orchestration_service")
110129
and app.state.orchestration_service is not None
111130
):
112131
app.state.orchestration_service.session_store = app.state.session_store
132+
app.state.orchestration_service.conversation_history_store = (
133+
app.state.conversation_history_store
134+
)
113135

114136
yield
115137

116138
# Shutdown
117139
logger.info("Shutting down LLM Orchestration Service API")
140+
141+
# Await any in-flight incremental summary tasks to avoid lost work.
142+
store = getattr(app.state, "conversation_history_store", None)
143+
if store is not None and store._pending_tasks:
144+
logger.info(
145+
f"Waiting for {len(store._pending_tasks)} pending summary task(s) to complete..."
146+
)
147+
await asyncio.gather(*store._pending_tasks, return_exceptions=True)
148+
118149
if (
119150
hasattr(app.state, "orchestration_service")
120151
and app.state.orchestration_service is not None
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Pydantic models for conversation history state."""
2+
3+
import time
4+
from typing import Optional
5+
6+
from pydantic import BaseModel, Field
7+
8+
9+
class ConversationRound(BaseModel):
10+
"""A single user+bot exchange in a conversation.
11+
12+
Stored as part of ``ConversationHistoryState`` in Redis, keyed by chat_id.
13+
"""
14+
15+
user_message: str = Field(..., description="The user's message text for this round")
16+
bot_message: str = Field(..., description="The bot's response text for this round")
17+
timestamp: float = Field(
18+
default_factory=time.time,
19+
description="Unix timestamp of when the round was recorded",
20+
)
21+
22+
23+
class ConversationHistoryState(BaseModel):
24+
"""Persisted conversation history for a chat session.
25+
26+
Keyed by chat_id in Redis with a sliding 30-minute TTL.
27+
Retains up to the most recent 10 rounds; older rounds are trimmed.
28+
An optional summary field holds a condensed representation of rounds
29+
that have been evicted. When a summarizer is injected into the store,
30+
it is automatically generated and persisted as rounds are evicted.
31+
If no summarizer is provided, the summary must be managed by the caller.
32+
"""
33+
34+
chat_id: str = Field(..., description="Unique conversation identifier")
35+
rounds: list[ConversationRound] = Field(
36+
default_factory=list,
37+
description="Ordered list of conversation rounds (newest last), capped at 10",
38+
)
39+
summary: Optional[str] = Field(
40+
default=None,
41+
description=(
42+
"Optional condensed summary of earlier conversation turns that have been "
43+
"evicted from the rounds list. Generated and stored by the caller."
44+
),
45+
)

src/tool_classifier/classifier.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@ def __init__(
127127
self.context_workflow = ContextWorkflowExecutor(
128128
llm_manager=llm_manager,
129129
orchestration_service=orchestration_service,
130+
conversation_history_store=getattr(
131+
orchestration_service, "conversation_history_store", None
132+
),
130133
)
131134
self.rag_workflow = RAGWorkflowExecutor(
132135
orchestration_service=orchestration_service,

0 commit comments

Comments
 (0)