[KM-626][AI] ChatHandler: gated structured->slow-path wiring
Browse filesAdd an off-by-default slow analytical path to ChatHandler. When
enable_slow_path=True, a `structured` intent routes to SlowPathCoordinator
(Planner -> TaskRunner -> Assembler) instead of the single-query QueryService
path, streaming the assembled chat_answer as SSE.
- New keyword-only ctor params: enable_slow_path (default False) and an
injectable slow_path_coordinator_factory for tests.
- _get_slow_path_coordinator is the composition root: builds a per-request
CompositeToolInvoker (data-access needs user_id + CatalogReader) so the
slow_path/ package stays tool-agnostic (INV-7).
- Flag defaults off and chat.py constructs ChatHandler() unchanged, so live
behavior is identical.
Gated on two stubs before flipping on: _stub_business_context (-> lead's real
BusinessContext) and analysis_record persistence (no store yet).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- src/agents/chat_handler.py +97 -1
|
@@ -23,7 +23,7 @@ inject mocks).
|
|
| 23 |
from __future__ import annotations
|
| 24 |
|
| 25 |
import json
|
| 26 |
-
from collections.abc import AsyncIterator
|
| 27 |
from typing import TYPE_CHECKING, Any
|
| 28 |
|
| 29 |
from langchain_core.messages import BaseMessage
|
|
@@ -38,6 +38,8 @@ if TYPE_CHECKING:
|
|
| 38 |
from ..catalog.reader import CatalogReader
|
| 39 |
from ..query.service import QueryService
|
| 40 |
from ..retrieval.router import RetrievalRouter
|
|
|
|
|
|
|
| 41 |
|
| 42 |
logger = get_logger("chat_handler")
|
| 43 |
|
|
@@ -62,12 +64,23 @@ class ChatHandler:
|
|
| 62 |
catalog_reader: CatalogReader | None = None,
|
| 63 |
query_service: QueryService | None = None,
|
| 64 |
document_retriever: RetrievalRouter | None = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
) -> None:
|
| 66 |
self._intent_router = intent_router
|
| 67 |
self._answer_agent = answer_agent
|
| 68 |
self._catalog_reader = catalog_reader
|
| 69 |
self._query_service = query_service
|
| 70 |
self._document_retriever = document_retriever
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
# ------------------------------------------------------------------
|
| 73 |
# Lazy default-dep builders
|
|
@@ -134,6 +147,10 @@ class ChatHandler:
|
|
| 134 |
if decision.source_hint == "structured":
|
| 135 |
try:
|
| 136 |
catalog = await self._get_catalog_reader().read(user_id, "structured")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
query_result = await self._get_query_service().run(
|
| 138 |
user_id, rewritten, catalog
|
| 139 |
)
|
|
@@ -189,6 +206,85 @@ class ChatHandler:
|
|
| 189 |
|
| 190 |
yield {"event": "done", "data": ""}
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
def _build_sources(
|
| 194 |
source_hint: str,
|
|
|
|
| 23 |
from __future__ import annotations
|
| 24 |
|
| 25 |
import json
|
| 26 |
+
from collections.abc import AsyncIterator, Callable
|
| 27 |
from typing import TYPE_CHECKING, Any
|
| 28 |
|
| 29 |
from langchain_core.messages import BaseMessage
|
|
|
|
| 38 |
from ..catalog.reader import CatalogReader
|
| 39 |
from ..query.service import QueryService
|
| 40 |
from ..retrieval.router import RetrievalRouter
|
| 41 |
+
from .planner.contracts import BusinessContext
|
| 42 |
+
from .slow_path.coordinator import SlowPathCoordinator
|
| 43 |
|
| 44 |
logger = get_logger("chat_handler")
|
| 45 |
|
|
|
|
| 64 |
catalog_reader: CatalogReader | None = None,
|
| 65 |
query_service: QueryService | None = None,
|
| 66 |
document_retriever: RetrievalRouter | None = None,
|
| 67 |
+
*,
|
| 68 |
+
enable_slow_path: bool = False,
|
| 69 |
+
slow_path_coordinator_factory: (
|
| 70 |
+
Callable[[str], SlowPathCoordinator] | None
|
| 71 |
+
) = None,
|
| 72 |
) -> None:
|
| 73 |
self._intent_router = intent_router
|
| 74 |
self._answer_agent = answer_agent
|
| 75 |
self._catalog_reader = catalog_reader
|
| 76 |
self._query_service = query_service
|
| 77 |
self._document_retriever = document_retriever
|
| 78 |
+
# Slow analytical path (Planner -> TaskRunner -> Assembler). OFF by default:
|
| 79 |
+
# gated until the lead's real BusinessContext + analysis_record persistence
|
| 80 |
+
# land. When True, `structured` intents route here instead of the single-query
|
| 81 |
+
# QueryService path. The factory is injectable for tests.
|
| 82 |
+
self._enable_slow_path = enable_slow_path
|
| 83 |
+
self._slow_path_factory = slow_path_coordinator_factory
|
| 84 |
|
| 85 |
# ------------------------------------------------------------------
|
| 86 |
# Lazy default-dep builders
|
|
|
|
| 147 |
if decision.source_hint == "structured":
|
| 148 |
try:
|
| 149 |
catalog = await self._get_catalog_reader().read(user_id, "structured")
|
| 150 |
+
if self._enable_slow_path:
|
| 151 |
+
async for event in self._run_slow_path(user_id, rewritten, catalog):
|
| 152 |
+
yield event
|
| 153 |
+
return
|
| 154 |
query_result = await self._get_query_service().run(
|
| 155 |
user_id, rewritten, catalog
|
| 156 |
)
|
|
|
|
| 206 |
|
| 207 |
yield {"event": "done", "data": ""}
|
| 208 |
|
| 209 |
+
# ------------------------------------------------------------------
|
| 210 |
+
# Slow analytical path (gated, off by default)
|
| 211 |
+
# ------------------------------------------------------------------
|
| 212 |
+
|
| 213 |
+
def _get_slow_path_coordinator(self, user_id: str) -> SlowPathCoordinator:
|
| 214 |
+
"""Build the per-request slow-path coordinator (composition root).
|
| 215 |
+
|
| 216 |
+
The data-access tools need the authenticated `user_id` + `CatalogReader`,
|
| 217 |
+
so the `CompositeToolInvoker` is constructed per request. The slow-path
|
| 218 |
+
agent code stays tool-agnostic (INV-7) — only here, the composition root,
|
| 219 |
+
do we name concrete tool implementations.
|
| 220 |
+
"""
|
| 221 |
+
if self._slow_path_factory is not None:
|
| 222 |
+
return self._slow_path_factory(user_id)
|
| 223 |
+
|
| 224 |
+
from ..tools.data_access import DataAccessToolInvoker
|
| 225 |
+
from ..tools.invoker import AnalyticsToolInvoker, CompositeToolInvoker
|
| 226 |
+
from .planner.registry import default_registry
|
| 227 |
+
from .planner.service import PlannerService
|
| 228 |
+
from .slow_path.assembler import Assembler
|
| 229 |
+
from .slow_path.coordinator import SlowPathCoordinator
|
| 230 |
+
from .slow_path.task_runner import TaskRunner
|
| 231 |
+
|
| 232 |
+
invoker = CompositeToolInvoker(
|
| 233 |
+
DataAccessToolInvoker(user_id, self._get_catalog_reader()),
|
| 234 |
+
AnalyticsToolInvoker(),
|
| 235 |
+
)
|
| 236 |
+
registry = default_registry()
|
| 237 |
+
return SlowPathCoordinator(
|
| 238 |
+
PlannerService(), TaskRunner(invoker, registry), Assembler(), registry
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
async def _run_slow_path(
|
| 242 |
+
self,
|
| 243 |
+
user_id: str,
|
| 244 |
+
query: str,
|
| 245 |
+
catalog: Any,
|
| 246 |
+
) -> AsyncIterator[dict[str, Any]]:
|
| 247 |
+
"""Run the slow path and stream its assembled answer as SSE events.
|
| 248 |
+
|
| 249 |
+
STUB `BusinessContext` until the lead's real source lands; `analysis_record`
|
| 250 |
+
persistence is deferred (no store yet). `chat_answer` is emitted as a single
|
| 251 |
+
`chunk` (the Assembler returns the whole object — true token streaming is a
|
| 252 |
+
later step).
|
| 253 |
+
"""
|
| 254 |
+
from .planner.inputs import Constraints
|
| 255 |
+
|
| 256 |
+
coordinator = self._get_slow_path_coordinator(user_id)
|
| 257 |
+
context = _stub_business_context(user_id)
|
| 258 |
+
try:
|
| 259 |
+
result = await coordinator.run(context, catalog, query, Constraints())
|
| 260 |
+
except Exception as e:
|
| 261 |
+
logger.error("slow path failed", user_id=user_id, error=str(e))
|
| 262 |
+
yield {"event": "error", "data": f"Analysis failed: {e}"}
|
| 263 |
+
return
|
| 264 |
+
|
| 265 |
+
yield {"event": "sources", "data": json.dumps([])} # TODO: derive from record
|
| 266 |
+
yield {"event": "chunk", "data": result.chat_answer}
|
| 267 |
+
# TODO(persistence): persist result.analysis_record once a memory store exists.
|
| 268 |
+
yield {"event": "done", "data": ""}
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _stub_business_context(user_id: str) -> BusinessContext:
|
| 272 |
+
"""Minimal stand-in BusinessContext until the lead's real source lands.
|
| 273 |
+
|
| 274 |
+
The slow path requires a BusinessContext; `project_id` flows through as
|
| 275 |
+
`RunState.business_context_id`. TODO(lead): replace with a real
|
| 276 |
+
`get_business_context(user_id)` reader.
|
| 277 |
+
"""
|
| 278 |
+
from .planner.contracts import BusinessContext
|
| 279 |
+
|
| 280 |
+
return BusinessContext(
|
| 281 |
+
project_id=user_id,
|
| 282 |
+
industry="unknown",
|
| 283 |
+
completeness="partial",
|
| 284 |
+
business_description="(not yet captured — BusinessContext source pending)",
|
| 285 |
+
scale_and_scope="(unknown)",
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
|
| 289 |
def _build_sources(
|
| 290 |
source_hint: str,
|