[KM-626][AI] Slow-path wiring prep: context + persistence seams, retrieve_documents few-shot
Browse filesThree 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 +23 -28
- src/agents/planner/business_context.py +31 -0
- src/agents/planner/examples.py +111 -0
- src/agents/slow_path/store.py +44 -0
|
@@ -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
|
| 80 |
-
#
|
| 81 |
-
#
|
| 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 |
-
|
| 250 |
-
|
| 251 |
-
`chunk` (the Assembler returns the whole
|
| 252 |
-
later step).
|
| 253 |
"""
|
|
|
|
| 254 |
from .planner.inputs import Constraints
|
| 255 |
|
| 256 |
coordinator = self._get_slow_path_coordinator(user_id)
|
| 257 |
-
context =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
@@ -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 |
+
)
|
|
@@ -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 |
|
|
@@ -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 |
+
)
|