Rifqi Hafizuddin Claude Opus 4.8 commited on
Commit
50df057
·
1 Parent(s): 37b8384

[KM-626][AI] ChatHandler: gated structured->slow-path wiring

Browse files

Add 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>

Files changed (1) hide show
  1. src/agents/chat_handler.py +97 -1
src/agents/chat_handler.py CHANGED
@@ -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,