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

[KM-626][AI] Slow-path wiring prep: context + persistence seams, retrieve_documents few-shot

Browse files

Three small steps that de-couple the live wiring from the remaining blockers so
flipping enable_slow_path on later is a one-liner.

- BusinessContext seam: new get_business_context(user_id) (planner/business_context.py)
is the single place context is read — returns a stub today, swap the body when the
lead's real source lands. ChatHandler now reads through it.
- AnalysisStore seam: new slow_path/store.py (Protocol + NullAnalysisStore no-op that
logs only). ChatHandler persists the analysis_record through it after streaming
(never breaks the answer on failure). Real Postgres-backed store in the catalog DB
(Neon dataeyond) is a TODO — table not created yet.
- Planner few-shot: add Example C (retrieve_documents) — a mixed structured +
unstructured plan showing an independent document-retrieval branch (NL query, no
${t} placeholder, runs in parallel). The Planner had no retrieve_documents example.

Verified: slow_path + planner suites green (52 passed); live RUN_PLANNER_EVAL still
passes with the new few-shot (no regression).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

src/agents/chat_handler.py CHANGED
@@ -38,8 +38,8 @@ if TYPE_CHECKING:
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
 
@@ -69,6 +69,7 @@ class ChatHandler:
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
@@ -76,11 +77,12 @@ class ChatHandler:
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
@@ -238,6 +240,13 @@ class ChatHandler:
238
  PlannerService(), TaskRunner(invoker, registry), Assembler(), registry
239
  )
240
 
 
 
 
 
 
 
 
241
  async def _run_slow_path(
242
  self,
243
  user_id: str,
@@ -246,15 +255,16 @@ class ChatHandler:
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:
@@ -264,28 +274,13 @@ class ChatHandler:
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,
291
  user_id: str,
 
38
  from ..catalog.reader import CatalogReader
39
  from ..query.service import QueryService
40
  from ..retrieval.router import RetrievalRouter
 
41
  from .slow_path.coordinator import SlowPathCoordinator
42
+ from .slow_path.store import AnalysisStore
43
 
44
  logger = get_logger("chat_handler")
45
 
 
69
  slow_path_coordinator_factory: (
70
  Callable[[str], SlowPathCoordinator] | None
71
  ) = None,
72
+ analysis_store: AnalysisStore | None = None,
73
  ) -> None:
74
  self._intent_router = intent_router
75
  self._answer_agent = answer_agent
 
77
  self._query_service = query_service
78
  self._document_retriever = document_retriever
79
  # Slow analytical path (Planner -> TaskRunner -> Assembler). OFF by default:
80
+ # gated until the lead's real BusinessContext lands. When True, `structured`
81
+ # intents route here instead of the single-query QueryService path. The
82
+ # factory + store are injectable for tests.
83
  self._enable_slow_path = enable_slow_path
84
  self._slow_path_factory = slow_path_coordinator_factory
85
+ self._analysis_store = analysis_store
86
 
87
  # ------------------------------------------------------------------
88
  # Lazy default-dep builders
 
240
  PlannerService(), TaskRunner(invoker, registry), Assembler(), registry
241
  )
242
 
243
+ def _get_analysis_store(self) -> AnalysisStore:
244
+ if self._analysis_store is None:
245
+ from .slow_path.store import NullAnalysisStore
246
+
247
+ self._analysis_store = NullAnalysisStore()
248
+ return self._analysis_store
249
+
250
  async def _run_slow_path(
251
  self,
252
  user_id: str,
 
255
  ) -> AsyncIterator[dict[str, Any]]:
256
  """Run the slow path and stream its assembled answer as SSE events.
257
 
258
+ Context comes from the `get_business_context` seam (a stub today); the
259
+ `analysis_record` is persisted via the `AnalysisStore` seam (a no-op today).
260
+ `chat_answer` is emitted as a single `chunk` (the Assembler returns the whole
261
+ object — true token streaming is a later step).
262
  """
263
+ from .planner.business_context import get_business_context
264
  from .planner.inputs import Constraints
265
 
266
  coordinator = self._get_slow_path_coordinator(user_id)
267
+ context = await get_business_context(user_id)
268
  try:
269
  result = await coordinator.run(context, catalog, query, Constraints())
270
  except Exception as e:
 
274
 
275
  yield {"event": "sources", "data": json.dumps([])} # TODO: derive from record
276
  yield {"event": "chunk", "data": result.chat_answer}
277
+ try:
278
+ await self._get_analysis_store().save(result.analysis_record)
279
+ except Exception as e: # persistence must never break the user's answer
280
+ logger.error("analysis_record persist failed", user_id=user_id, error=str(e))
281
  yield {"event": "done", "data": ""}
282
 
283
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  def _build_sources(
285
  source_hint: str,
286
  user_id: str,
src/agents/planner/business_context.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BusinessContext reader — the single seam the slow path reads context through.
2
+
3
+ `get_business_context(user_id)` is the one place the live flow obtains a
4
+ `BusinessContext`. Today it returns a minimal STUB so the slow path runs end to
5
+ end; when the lead's real Business Understanding source lands, swap the body here
6
+ (read the interview / stored context) and nothing upstream changes.
7
+
8
+ See AGENT_ARCHITECTURE_CONTEXT_new.md §7.1.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .contracts import BusinessContext
14
+
15
+
16
+ async def get_business_context(user_id: str) -> BusinessContext:
17
+ """Return the user's BusinessContext.
18
+
19
+ STUB until the lead's real source lands. `project_id` flows through as
20
+ `RunState.business_context_id`. Async so the real implementation (a DB / store
21
+ read) fits without changing this signature.
22
+
23
+ TODO(lead): replace the body with the real read (Business Understanding store).
24
+ """
25
+ return BusinessContext(
26
+ project_id=user_id,
27
+ industry="unknown",
28
+ completeness="partial",
29
+ business_description="(not yet captured — BusinessContext source pending)",
30
+ scale_and_scope="(unknown)",
31
+ )
src/agents/planner/examples.py CHANGED
@@ -191,9 +191,120 @@ _EXAMPLE_B = TaskList(
191
  )
192
 
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  EXAMPLES: list[tuple[str, TaskList]] = [
195
  ("Which product categories drove last quarter's revenue?", _EXAMPLE_A),
196
  ("How has monthly revenue trended by region this year, and what's unusual?", _EXAMPLE_B),
 
197
  ]
198
 
199
 
 
191
  )
192
 
193
 
194
+ # --------------------------------------------------------------------------- #
195
+ # Example C — mixed structured + unstructured.
196
+ # "Revenue dipped in Q1 — what happened?"
197
+ # Shows: a structured branch (query -> analyze_trend) runs alongside an
198
+ # INDEPENDENT retrieve_documents branch that pulls qualitative context. Note
199
+ # retrieve_documents takes a natural-language `query` (NOT a `${t<id>}` data
200
+ # placeholder — it is a source, not a consumer) and can run in parallel; the
201
+ # Assembler folds the document context into the explanation.
202
+ # --------------------------------------------------------------------------- #
203
+
204
+ _EXAMPLE_C = TaskList(
205
+ plan_id="example_c",
206
+ goal_restated="Explain Q1's revenue dip using both the numbers and the qualitative record.",
207
+ assumptions=["'Q1' = 2026-01-01 to 2026-03-31."],
208
+ open_questions=[],
209
+ tasks=[
210
+ Task(
211
+ id="t1",
212
+ stage="data_understanding",
213
+ objective="Confirm the sales source exposes order date and revenue.",
214
+ tool_calls=[ToolCall(tool="describe_source", args={"source_id": "src_sales"})],
215
+ expected_output="source_shape",
216
+ success_criteria="describe_source returns the orders table with date and revenue.",
217
+ depends_on=[],
218
+ parallelizable_with=["t4"],
219
+ estimated_cost="low",
220
+ ),
221
+ Task(
222
+ id="t2",
223
+ stage="data_preparation",
224
+ objective="Pull Q1 order dates and revenue.",
225
+ tool_calls=[
226
+ ToolCall(
227
+ tool="query_structured",
228
+ args={
229
+ "ir": {
230
+ "source_id": "src_sales",
231
+ "table_id": "t_orders",
232
+ "select": [
233
+ {
234
+ "kind": "column",
235
+ "column_id": "c_order_date",
236
+ "alias": "order_date",
237
+ },
238
+ {"kind": "column", "column_id": "c_revenue", "alias": "revenue"},
239
+ ],
240
+ "filters": [
241
+ {
242
+ "column_id": "c_order_date",
243
+ "op": "between",
244
+ "value": ["2026-01-01", "2026-03-31"],
245
+ "value_type": "date",
246
+ }
247
+ ],
248
+ "limit": 10000,
249
+ }
250
+ },
251
+ )
252
+ ],
253
+ expected_output="q1_rows",
254
+ success_criteria="Produced Q1 order rows with date and revenue.",
255
+ depends_on=["t1"],
256
+ parallelizable_with=[],
257
+ estimated_cost="medium",
258
+ ),
259
+ Task(
260
+ id="t3",
261
+ stage="evaluation",
262
+ objective="Summarize the Q1 monthly revenue trend to locate the dip.",
263
+ tool_calls=[
264
+ ToolCall(
265
+ tool="analyze_trend",
266
+ args={
267
+ "data": "${t2}",
268
+ "date_column": "order_date",
269
+ "value_column": "revenue",
270
+ "freq": "month",
271
+ "agg": "sum",
272
+ },
273
+ )
274
+ ],
275
+ expected_output="q1_trend",
276
+ success_criteria="Produced a per-month revenue series showing where revenue fell.",
277
+ depends_on=["t2"],
278
+ parallelizable_with=[],
279
+ estimated_cost="low",
280
+ ),
281
+ Task(
282
+ id="t4",
283
+ stage="data_understanding",
284
+ objective="Retrieve qualitative context on Q1 operational events behind a dip.",
285
+ tool_calls=[
286
+ ToolCall(
287
+ tool="retrieve_documents",
288
+ args={
289
+ "query": "operational issues, outages, or notable events in Q1 2026",
290
+ "top_k": 5,
291
+ },
292
+ )
293
+ ],
294
+ expected_output="q1_context_chunks",
295
+ success_criteria="Produced relevant document chunks about Q1 operations.",
296
+ depends_on=[],
297
+ parallelizable_with=["t1"],
298
+ estimated_cost="low",
299
+ ),
300
+ ],
301
+ )
302
+
303
+
304
  EXAMPLES: list[tuple[str, TaskList]] = [
305
  ("Which product categories drove last quarter's revenue?", _EXAMPLE_A),
306
  ("How has monthly revenue trended by region this year, and what's unusual?", _EXAMPLE_B),
307
+ ("Revenue dipped in Q1 — what happened?", _EXAMPLE_C),
308
  ]
309
 
310
 
src/agents/slow_path/store.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AnalysisStore — the seam the slow path persists its AnalysisRecord through.
2
+
3
+ The Assembler produces an `AnalysisRecord` (the faithful, structured record of a
4
+ run — §8.3, INV-4). Persisting it is a separate concern from streaming the answer,
5
+ so it sits behind this one-method seam.
6
+
7
+ `NullAnalysisStore` is the default: it logs that a record was produced but stores
8
+ nothing, because the backing table does not exist yet. The plan is to store records
9
+ in the **same catalog DB** (Neon `dataeyond`, `settings.postgres_connstring`).
10
+
11
+ TODO(persistence): add a Postgres-backed `AnalysisStore` writing an
12
+ `analysis_records` table in the catalog DB, keyed on
13
+ (business_context_id, plan_id, created_at), then inject it into ChatHandler.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Protocol, runtime_checkable
19
+
20
+ from src.middlewares.logging import get_logger
21
+
22
+ from .schemas import AnalysisRecord
23
+
24
+ logger = get_logger("analysis_store")
25
+
26
+
27
+ @runtime_checkable
28
+ class AnalysisStore(Protocol):
29
+ """Persist a completed analysis. Implementations must never raise on the
30
+ caller's path — a persistence failure must not break the user's answer."""
31
+
32
+ async def save(self, record: AnalysisRecord) -> None: ...
33
+
34
+
35
+ class NullAnalysisStore:
36
+ """Default no-op store: logs the record, persists nothing (no table yet)."""
37
+
38
+ async def save(self, record: AnalysisRecord) -> None:
39
+ logger.info(
40
+ "analysis_record produced (not persisted — no store configured)",
41
+ plan_id=record.plan_id,
42
+ business_context_id=record.business_context_id,
43
+ n_tasks=len(record.tasks_run),
44
+ )